miden_node_utils/
tasks.rs1use std::collections::HashMap;
2use std::future::Future;
3
4use anyhow::Context;
5use tokio::task::{Id, JoinError, JoinSet};
6
7use crate::shutdown::CancellationToken;
8
9pub struct Tasks {
13 handles: JoinSet<anyhow::Result<()>>,
14 names: HashMap<Id, String>,
15}
16
17impl Default for Tasks {
18 fn default() -> Self {
19 Self {
20 handles: JoinSet::new(),
21 names: HashMap::new(),
22 }
23 }
24}
25
26impl Tasks {
27 pub fn new() -> Self {
29 Self::default()
30 }
31
32 pub fn spawn(
34 &mut self,
35 name: impl Into<String>,
36 task: impl Future<Output = anyhow::Result<()>> + Send + 'static,
37 ) -> Id {
38 let id = self.handles.spawn(task).id();
39 self.names.insert(id, name.into());
40 id
41 }
42
43 pub fn spawn_infallible(
45 &mut self,
46 name: impl Into<String>,
47 task: impl Future<Output = ()> + Send + 'static,
48 ) -> Id {
49 self.spawn(name, async move {
50 task.await;
51 Ok(())
52 })
53 }
54
55 pub async fn join_next(&mut self) -> Option<(String, Result<anyhow::Result<()>, JoinError>)> {
57 let result = self.handles.join_next_with_id().await?;
58 let id = match &result {
59 Ok((id, _)) => *id,
60 Err(err) => err.id(),
61 };
62 let name = self.names.remove(&id).unwrap_or_else(|| "unknown".to_string());
63 let result = result.map(|(_, output)| output);
64
65 Some((name, result))
66 }
67
68 pub fn is_empty(&self) -> bool {
70 self.handles.is_empty()
71 }
72
73 pub fn len(&self) -> usize {
75 self.handles.len()
76 }
77
78 pub async fn join_next_as_error(&mut self) -> anyhow::Result<()> {
82 let Some((task, result)) = self.join_next().await else {
83 anyhow::bail!("task set is empty");
84 };
85
86 Self::unexpected_completion(&task, result)
87 }
88
89 pub async fn join_next_or_cancelled(&mut self, token: CancellationToken) -> anyhow::Result<()> {
102 let mut outcome = Ok(());
103 while !token.is_cancelled() {
104 tokio::select! {
105 biased;
106 () = token.cancelled() => break,
107 result = self.join_next() => {
108 let Some((task, result)) = result else {
109 anyhow::bail!("task set is empty");
110 };
111 outcome = Self::unexpected_completion(&task, result);
112 token.cancel();
114 },
115 }
116 }
117
118 while let Some((task, result)) = self.join_next().await {
119 match (&outcome, Self::shutdown_completion(&task, result)) {
120 (Ok(()), result) => outcome = result,
122 (Err(_), Err(err)) => {
125 tracing::warn!(task = %task, error = %format!("{err:#}"), "task failed during shutdown");
126 },
127 (Err(_), Ok(())) => {},
129 }
130 }
131
132 outcome
133 }
134
135 fn unexpected_completion(
141 task: &str,
142 result: Result<anyhow::Result<()>, JoinError>,
143 ) -> anyhow::Result<()> {
144 match result {
145 Ok(Ok(())) => anyhow::bail!("task {task} completed unexpectedly"),
146 Ok(Err(err)) => Err(err).with_context(|| format!("task {task} failed")),
147 Err(err) => Err(err).with_context(|| format!("task {task} failed to join")),
148 }
149 }
150
151 fn shutdown_completion(
157 task: &str,
158 result: Result<anyhow::Result<()>, JoinError>,
159 ) -> anyhow::Result<()> {
160 match result {
161 Ok(Ok(())) => Ok(()),
162 Ok(Err(err)) => Err(err).with_context(|| format!("task {task} failed during shutdown")),
163 Err(err) if err.is_cancelled() => Ok(()),
164 Err(err) => Err(err).with_context(|| format!("task {task} failed to join")),
165 }
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use std::time::Duration;
172
173 use super::*;
174
175 #[tokio::test]
176 async fn join_next_or_cancelled_accepts_clean_task_completion_after_cancellation() {
177 let token = crate::shutdown::CancellationToken::new();
178 let mut tasks = Tasks::new();
179 tasks.spawn("worker", {
180 let token = token.clone();
181 async move {
182 token.cancelled().await;
183 Ok(())
184 }
185 });
186
187 token.cancel();
188
189 tasks
190 .join_next_or_cancelled(token)
191 .await
192 .expect("clean shutdown should not be treated as an error");
193 }
194
195 #[tokio::test]
196 async fn join_next_or_cancelled_treats_task_completion_before_cancellation_as_error() {
197 let token = crate::shutdown::CancellationToken::new();
198 let mut tasks = Tasks::new();
199 tasks.spawn("worker", async { Ok(()) });
200
201 let err = tasks
202 .join_next_or_cancelled(token)
203 .await
204 .expect_err("unexpected task completion should fail before shutdown");
205
206 assert_eq!(err.to_string(), "task worker completed unexpectedly");
207 }
208
209 #[tokio::test]
210 async fn join_next_or_cancelled_drains_remaining_tasks_after_a_failure() {
211 use std::sync::Arc;
212 use std::sync::atomic::{AtomicBool, Ordering};
213
214 let token = crate::shutdown::CancellationToken::new();
215 let mut tasks = Tasks::new();
216 let survivor_finished = Arc::new(AtomicBool::new(false));
217
218 tasks.spawn("failing", async { anyhow::bail!("boom") });
219 tasks.spawn("survivor", {
220 let token = token.clone();
221 let finished = Arc::clone(&survivor_finished);
222 async move {
223 token.cancelled().await;
224 tokio::time::sleep(Duration::from_millis(10)).await;
226 finished.store(true, Ordering::Relaxed);
227 Ok(())
228 }
229 });
230
231 let err = tasks
232 .join_next_or_cancelled(token.clone())
233 .await
234 .expect_err("the failing task's error should be returned");
235
236 assert_eq!(err.to_string(), "task failing failed");
237 assert!(token.is_cancelled(), "a task failure should trigger shutdown");
238 assert!(tasks.is_empty(), "all tasks should be drained before returning");
239 assert!(
240 survivor_finished.load(Ordering::Relaxed),
241 "surviving tasks should shut down gracefully, not be aborted",
242 );
243 }
244
245 #[tokio::test]
246 async fn join_next_or_cancelled_drains_past_failures_during_shutdown() {
247 use std::sync::Arc;
248 use std::sync::atomic::{AtomicBool, Ordering};
249
250 let token = crate::shutdown::CancellationToken::new();
251 let mut tasks = Tasks::new();
252 let survivor_finished = Arc::new(AtomicBool::new(false));
253
254 tasks.spawn("failing", {
255 let token = token.clone();
256 async move {
257 token.cancelled().await;
258 anyhow::bail!("boom")
259 }
260 });
261 tasks.spawn("survivor", {
262 let token = token.clone();
263 let finished = Arc::clone(&survivor_finished);
264 async move {
265 token.cancelled().await;
266 tokio::time::sleep(Duration::from_millis(10)).await;
267 finished.store(true, Ordering::Relaxed);
268 Ok(())
269 }
270 });
271
272 token.cancel();
273
274 let err = tasks
275 .join_next_or_cancelled(token)
276 .await
277 .expect_err("a failure during shutdown should be reported");
278
279 assert_eq!(err.to_string(), "task failing failed during shutdown");
280 assert!(tasks.is_empty(), "draining should continue past the failed task");
281 assert!(
282 survivor_finished.load(Ordering::Relaxed),
283 "surviving tasks should shut down gracefully, not be aborted",
284 );
285 }
286
287 #[tokio::test]
288 async fn join_next_or_cancelled_waits_for_all_tasks_to_complete_after_cancellation() {
289 let token = crate::shutdown::CancellationToken::new();
290 let mut tasks = Tasks::new();
291 tasks.spawn("worker-a", {
292 let token = token.clone();
293 async move {
294 token.cancelled().await;
295 Ok(())
296 }
297 });
298 tasks.spawn("worker-b", {
299 let token = token.clone();
300 async move {
301 token.cancelled().await;
302 tokio::time::sleep(Duration::from_millis(10)).await;
303 Ok(())
304 }
305 });
306
307 token.cancel();
308
309 tasks
310 .join_next_or_cancelled(token)
311 .await
312 .expect("shutdown should wait for all clean task exits");
313 assert!(tasks.is_empty());
314 }
315}