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