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