Skip to main content

rskit_stream/
task.rs

1//! Cancellable owned tasks.
2//!
3//! [`SpawnedTask`] is the canonical owner for a background task that must be cooperatively stopped:
4//! it bundles a `CancellationToken` with the `JoinHandle`, so every task has explicit ownership,
5//! cancellation, and a bounded, drain-then-abort shutdown. [`TaskGroup`] owns a set of such tasks
6//! and shuts them all down together.
7//! This replaces the hand-rolled `cancel + handle` pairs duplicated across consumers, watchers,
8//! and servers.
9
10use std::time::Duration;
11
12use tokio::task::JoinHandle;
13use tokio_util::sync::CancellationToken;
14
15/// A single background task with cooperative cancellation.
16///
17/// Created by [`SpawnedTask::spawn`], which hands the body a child `CancellationToken` to `select!` on.
18/// Stop it with [`shutdown`](Self::shutdown) (cancel → await up to a grace period → abort)
19/// so a wedged task can never block shutdown indefinitely.
20/// Extra teardown (closing connections, flushing buffers) stays with the caller.
21#[derive(Debug)]
22pub struct SpawnedTask {
23    cancel: CancellationToken,
24    handle: JoinHandle<()>,
25}
26
27impl SpawnedTask {
28    /// Spawn `body` with a fresh cancellation token passed to the closure.
29    ///
30    /// The body should `select!` on `token.cancelled()` to exit promptly.
31    pub fn spawn<F, Fut>(body: F) -> Self
32    where
33        F: FnOnce(CancellationToken) -> Fut,
34        Fut: Future<Output = ()> + Send + 'static,
35    {
36        let cancel = CancellationToken::new();
37        let handle = tokio::spawn(body(cancel.clone()));
38        Self { cancel, handle }
39    }
40
41    /// Spawn `body` with the supplied cancellation token (e.g. a shared parent).
42    pub fn spawn_with<Fut>(cancel: CancellationToken, body: Fut) -> Self
43    where
44        Fut: Future<Output = ()> + Send + 'static,
45    {
46        let handle = tokio::spawn(body);
47        Self { cancel, handle }
48    }
49
50    /// Adopt an already-spawned `JoinHandle` paired with the token that stops it.
51    /// For callers that spawn the task themselves but still want the bounded drain-then-abort shutdown.
52    #[must_use]
53    pub const fn from_parts(cancel: CancellationToken, handle: JoinHandle<()>) -> Self {
54        Self { cancel, handle }
55    }
56
57    /// Signal the task to stop without waiting for it to finish.
58    pub fn cancel(&self) {
59        self.cancel.cancel();
60    }
61
62    /// Return `true` once the task has finished.
63    #[must_use]
64    pub fn is_finished(&self) -> bool {
65        self.handle.is_finished()
66    }
67
68    /// A clone of the task's cancellation token, for cooperative shutdown.
69    #[must_use]
70    pub fn cancellation(&self) -> CancellationToken {
71        self.cancel.clone()
72    }
73
74    /// Cancel the task and wait up to `grace` for it to drain, aborting if it overruns. Always resolves;
75    /// never blocks shutdown indefinitely.
76    pub async fn shutdown(self, grace: Duration) {
77        self.cancel.cancel();
78        let mut handle = self.handle;
79        if tokio::time::timeout(grace, &mut handle).await.is_err() {
80            handle.abort();
81            let _ = handle.await;
82        }
83    }
84
85    /// Abort the task immediately without waiting.
86    pub fn abort(self) {
87        self.handle.abort();
88    }
89
90    /// Wait for the task to finish without cancelling it.
91    pub async fn join(self) {
92        let _ = self.handle.await;
93    }
94}
95
96/// A set of [`SpawnedTask`]s shut down together.
97///
98/// Each task owns its own cancellation token; [`shutdown`](Self::shutdown) cancels all of them,
99/// then drains each within a per-task grace period, aborting stragglers
100/// so a single wedged task cannot stall teardown.
101#[derive(Debug, Default)]
102pub struct TaskGroup {
103    tasks: Vec<SpawnedTask>,
104}
105
106impl TaskGroup {
107    /// Create an empty group.
108    #[must_use]
109    pub const fn new() -> Self {
110        Self { tasks: Vec::new() }
111    }
112
113    /// Add a task to the group.
114    pub fn push(&mut self, task: SpawnedTask) {
115        self.tasks.push(task);
116    }
117
118    /// Number of tasks currently owned.
119    #[must_use]
120    pub const fn len(&self) -> usize {
121        self.tasks.len()
122    }
123
124    /// Whether the group owns no tasks.
125    #[must_use]
126    pub const fn is_empty(&self) -> bool {
127        self.tasks.is_empty()
128    }
129
130    /// Cancel every task without waiting.
131    pub fn cancel_all(&self) {
132        for task in &self.tasks {
133            task.cancel();
134        }
135    }
136
137    /// Cancel and drain every task, each within `grace`, aborting stragglers.
138    pub async fn shutdown(&mut self, grace: Duration) {
139        let tasks = std::mem::take(&mut self.tasks);
140        for task in &tasks {
141            task.cancel();
142        }
143        for task in tasks {
144            task.shutdown(grace).await;
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use std::sync::Arc;
153    use std::sync::atomic::{AtomicBool, Ordering};
154
155    #[tokio::test]
156    async fn shutdown_cancels_cooperative_task() {
157        let ran = Arc::new(AtomicBool::new(false));
158        let r = ran.clone();
159        let task = SpawnedTask::spawn(move |cancel| async move {
160            cancel.cancelled().await;
161            r.store(true, Ordering::SeqCst);
162        });
163        task.shutdown(Duration::from_secs(1)).await;
164        assert!(ran.load(Ordering::SeqCst));
165    }
166
167    #[tokio::test(start_paused = true)]
168    async fn shutdown_aborts_wedged_task() {
169        let task = SpawnedTask::spawn(|_cancel| async move {
170            std::future::pending::<()>().await;
171        });
172        // Returns even though the body ignores cancellation.
173        task.shutdown(Duration::from_millis(50)).await;
174    }
175
176    #[tokio::test]
177    async fn group_shuts_down_all() {
178        let mut group = TaskGroup::new();
179        for _ in 0..3 {
180            group.push(SpawnedTask::spawn(|cancel| async move {
181                cancel.cancelled().await;
182            }));
183        }
184        assert_eq!(group.len(), 3);
185        group.shutdown(Duration::from_secs(1)).await;
186        assert!(group.is_empty());
187    }
188
189    #[tokio::test]
190    async fn task_constructors_cancel_abort_and_join_paths() {
191        let token = CancellationToken::new();
192        let finished = Arc::new(AtomicBool::new(false));
193        let done = Arc::clone(&finished);
194        let task = SpawnedTask::spawn_with(token.clone(), async move {
195            token.cancelled().await;
196            done.store(true, Ordering::SeqCst);
197        });
198        assert!(!task.is_finished());
199        let cancellation = task.cancellation();
200        task.cancel();
201        task.join().await;
202        assert!(cancellation.is_cancelled());
203        assert!(finished.load(Ordering::SeqCst));
204
205        let token = CancellationToken::new();
206        let handle = tokio::spawn(async move {
207            std::future::pending::<()>().await;
208        });
209        let task = SpawnedTask::from_parts(token, handle);
210        task.abort();
211    }
212
213    #[tokio::test]
214    async fn cancel_all_cancels_without_waiting() {
215        let mut group = TaskGroup::new();
216        let token = CancellationToken::new();
217        let task = SpawnedTask::from_parts(
218            token.clone(),
219            tokio::spawn(async move {
220                std::future::pending::<()>().await;
221            }),
222        );
223        group.push(task);
224
225        group.cancel_all();
226
227        assert!(token.is_cancelled());
228        group.shutdown(Duration::from_millis(1)).await;
229    }
230}