Skip to main content

slt/context/
async_tasks.rs

1//! In-frame async task API (`Context::spawn` / `Context::poll`), feature-gated
2//! behind `async`. See issues #234, #334, and #343.
3
4use std::any::Any;
5use std::marker::PhantomData;
6use std::sync::Arc;
7use std::sync::mpsc::{Receiver, Sender};
8
9/// Terminal outcome of an in-frame task.
10///
11/// `Pending` is represented by `None` from the polling API; every value of
12/// this enum is terminal and delivered at most once.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum TaskOutcome<T> {
15    /// The future returned normally.
16    Completed(T),
17    /// The task was explicitly cancelled.
18    Cancelled,
19    /// The future panicked. The payload is normalized into a message.
20    Panicked(String),
21}
22
23enum ErasedTaskOutcome {
24    Completed(Box<dyn Any + Send>),
25    Cancelled,
26    Panicked(String),
27}
28
29type ResultMsg = (u64, ErasedTaskOutcome);
30
31#[derive(Clone, Copy)]
32struct CancelMsg {
33    id: u64,
34    retain_outcome: bool,
35}
36
37struct TaskJoin {
38    worker_abort: tokio::task::AbortHandle,
39    supervisor: tokio::task::JoinHandle<()>,
40    discard_outcome: bool,
41}
42
43/// Opaque handle returned by [`Context::spawn`](crate::Context::spawn).
44///
45/// Store the handle and pass it to [`Context::poll`](crate::Context::poll) on
46/// subsequent frames. Dropping it cancels the task and discards its outcome.
47#[must_use = "dropping a TaskHandle cancels the spawned task; store it to poll the result"]
48pub struct TaskHandle<T> {
49    pub(crate) id: u64,
50    cancel: Option<Sender<CancelMsg>>,
51    wake: Option<Arc<tokio::sync::Notify>>,
52    cancellation_requested: bool,
53    _marker: PhantomData<fn() -> T>,
54}
55
56impl<T> TaskHandle<T> {
57    fn new(id: u64, cancel: Sender<CancelMsg>, wake: Option<Arc<tokio::sync::Notify>>) -> Self {
58        Self {
59            id,
60            cancel: Some(cancel),
61            wake,
62            cancellation_requested: false,
63            _marker: PhantomData,
64        }
65    }
66
67    pub(crate) fn id(&self) -> u64 {
68        self.id
69    }
70
71    /// Request cancellation while keeping the terminal outcome observable.
72    ///
73    /// This method is idempotent. Continue polling the handle to observe
74    /// [`TaskOutcome::Cancelled`] once the runtime acknowledges the abort.
75    pub fn cancel(&mut self) {
76        if self.cancellation_requested {
77            return;
78        }
79        self.cancellation_requested = true;
80        if let Some(cancel) = self.cancel.as_ref() {
81            let _ = cancel.send(CancelMsg {
82                id: self.id,
83                retain_outcome: true,
84            });
85            if let Some(wake) = self.wake.as_ref() {
86                wake.notify_one();
87            }
88        }
89    }
90}
91
92impl<T> std::fmt::Debug for TaskHandle<T> {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.debug_struct("TaskHandle")
95            .field("id", &self.id)
96            .field("cancellation_requested", &self.cancellation_requested)
97            .finish()
98    }
99}
100
101impl<T> Drop for TaskHandle<T> {
102    fn drop(&mut self) {
103        if let Some(cancel) = self.cancel.take() {
104            let _ = cancel.send(CancelMsg {
105                id: self.id,
106                retain_outcome: false,
107            });
108            if let Some(wake) = self.wake.as_ref() {
109                wake.notify_one();
110            }
111        }
112    }
113}
114
115/// Per-session async task registry, round-tripped through [`Context`] each
116/// frame. Worker tasks are supervised so all normal, cancelled, and panicked
117/// exits produce a terminal message and release their join entry.
118pub(crate) struct AsyncTasks {
119    runtime: Option<tokio::runtime::Handle>,
120    next_id: u64,
121    joins: std::collections::HashMap<u64, TaskJoin>,
122    results: std::collections::HashMap<u64, ErasedTaskOutcome>,
123    result_tx: Option<Sender<ResultMsg>>,
124    result_rx: Option<Receiver<ResultMsg>>,
125    cancel_tx: Sender<CancelMsg>,
126    cancel_rx: Receiver<CancelMsg>,
127    /// Coalescing wake primitive for the owning render dispatcher. `Notify`
128    /// stores at most one permit, so completion bursts cannot grow a queue.
129    wake: Option<Arc<tokio::sync::Notify>>,
130}
131
132impl Default for AsyncTasks {
133    fn default() -> Self {
134        let (cancel_tx, cancel_rx) = std::sync::mpsc::channel();
135        Self {
136            runtime: None,
137            next_id: 0,
138            joins: std::collections::HashMap::new(),
139            results: std::collections::HashMap::new(),
140            result_tx: None,
141            result_rx: None,
142            cancel_tx,
143            cancel_rx,
144            wake: None,
145        }
146    }
147}
148
149impl Drop for AsyncTasks {
150    fn drop(&mut self) {
151        for (_, join) in self.joins.drain() {
152            join.worker_abort.abort();
153            join.supervisor.abort();
154        }
155        self.results.clear();
156    }
157}
158
159impl AsyncTasks {
160    pub(crate) fn set_runtime(&mut self, handle: tokio::runtime::Handle) {
161        self.runtime = Some(handle);
162    }
163
164    /// Install the coalescing wake primitive used by the owning render loop.
165    /// Task completion and handle cancellation each issue one notification.
166    pub(crate) fn set_waker(&mut self, wake: Arc<tokio::sync::Notify>) {
167        self.wake = Some(wake);
168    }
169
170    pub(crate) fn spawn<T: Send + 'static>(
171        &mut self,
172        fut: impl std::future::Future<Output = T> + Send + 'static,
173    ) -> TaskHandle<T> {
174        let runtime = self.runtime.clone().unwrap_or_else(|| {
175            panic!(
176                "Context::spawn requires an active Tokio runtime; call it inside \
177                 run_async() / run_async_with()"
178            )
179        });
180
181        if self.result_tx.is_none() {
182            let (tx, rx) = std::sync::mpsc::channel();
183            self.result_tx = Some(tx);
184            self.result_rx = Some(rx);
185        }
186        let result_tx = self
187            .result_tx
188            .clone()
189            .expect("result channel initialized immediately above");
190
191        let id = self.next_id;
192        self.next_id = self
193            .next_id
194            .checked_add(1)
195            .expect("in-frame async task id space exhausted");
196
197        let worker = runtime.spawn(fut);
198        let worker_abort = worker.abort_handle();
199        let completion_wake = self.wake.clone();
200        let supervisor = runtime.spawn(async move {
201            let outcome = match worker.await {
202                Ok(value) => ErasedTaskOutcome::Completed(Box::new(value)),
203                Err(error) if error.is_cancelled() => ErasedTaskOutcome::Cancelled,
204                Err(error) => ErasedTaskOutcome::Panicked(join_panic_message(error)),
205            };
206            let _ = result_tx.send((id, outcome));
207            if let Some(wake) = completion_wake {
208                wake.notify_one();
209            }
210        });
211        self.joins.insert(
212            id,
213            TaskJoin {
214                worker_abort,
215                supervisor,
216                discard_outcome: false,
217            },
218        );
219
220        TaskHandle::new(id, self.cancel_tx.clone(), self.wake.clone())
221    }
222
223    fn drain(&mut self) {
224        if let Some(rx) = self.result_rx.as_ref() {
225            while let Ok((id, outcome)) = rx.try_recv() {
226                let discard = self
227                    .joins
228                    .remove(&id)
229                    .is_some_and(|join| join.discard_outcome);
230                if !discard {
231                    self.results.insert(id, outcome);
232                }
233            }
234        }
235        while let Ok(cancel) = self.cancel_rx.try_recv() {
236            self.cancel(cancel);
237        }
238    }
239
240    pub(crate) fn maintain(&mut self) {
241        self.drain();
242    }
243
244    pub(crate) fn poll<T: 'static>(&mut self, id: u64) -> Option<T> {
245        match self.poll_outcome(id)? {
246            TaskOutcome::Completed(value) => Some(value),
247            TaskOutcome::Cancelled | TaskOutcome::Panicked(_) => None,
248        }
249    }
250
251    pub(crate) fn poll_outcome<T: 'static>(&mut self, id: u64) -> Option<TaskOutcome<T>> {
252        self.drain();
253        let outcome = self.results.remove(&id)?;
254        match outcome {
255            ErasedTaskOutcome::Completed(value) => match value.downcast::<T>() {
256                Ok(value) => Some(TaskOutcome::Completed(*value)),
257                Err(value) => {
258                    self.results.insert(id, ErasedTaskOutcome::Completed(value));
259                    None
260                }
261            },
262            ErasedTaskOutcome::Cancelled => Some(TaskOutcome::Cancelled),
263            ErasedTaskOutcome::Panicked(message) => Some(TaskOutcome::Panicked(message)),
264        }
265    }
266
267    fn cancel(&mut self, cancel: CancelMsg) {
268        if !cancel.retain_outcome {
269            self.results.remove(&cancel.id);
270        }
271        if let Some(join) = self.joins.get_mut(&cancel.id) {
272            join.discard_outcome |= !cancel.retain_outcome;
273            join.worker_abort.abort();
274        }
275    }
276}
277
278fn join_panic_message(error: tokio::task::JoinError) -> String {
279    debug_assert!(error.is_panic());
280    let payload = error.into_panic();
281    if let Some(message) = payload.downcast_ref::<&str>() {
282        (*message).to_owned()
283    } else if let Some(message) = payload.downcast_ref::<String>() {
284        message.clone()
285    } else {
286        "task panicked with a non-string payload".to_owned()
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use std::time::Duration;
294
295    async fn wait_for_outcome<T: 'static>(
296        tasks: &mut AsyncTasks,
297        handle: &TaskHandle<T>,
298    ) -> TaskOutcome<T> {
299        for _ in 0..200 {
300            if let Some(outcome) = tasks.poll_outcome(handle.id()) {
301                return outcome;
302            }
303            tokio::time::sleep(Duration::from_millis(1)).await;
304        }
305        panic!("task outcome was not delivered within timeout");
306    }
307
308    #[tokio::test]
309    async fn completed_cancelled_and_panicked_tasks_are_reaped() {
310        let mut tasks = AsyncTasks::default();
311        tasks.set_runtime(tokio::runtime::Handle::current());
312
313        let completed = tasks.spawn(async { 7u32 });
314        assert_eq!(
315            wait_for_outcome(&mut tasks, &completed).await,
316            TaskOutcome::Completed(7)
317        );
318        assert!(!tasks.joins.contains_key(&completed.id()));
319
320        let mut cancelled = tasks.spawn(async {
321            tokio::time::sleep(Duration::from_secs(60)).await;
322            9u32
323        });
324        cancelled.cancel();
325        assert_eq!(
326            wait_for_outcome(&mut tasks, &cancelled).await,
327            TaskOutcome::Cancelled
328        );
329        assert!(!tasks.joins.contains_key(&cancelled.id()));
330
331        let panicked = tasks.spawn(async {
332            panic!("task exploded");
333            #[allow(unreachable_code)]
334            11u32
335        });
336        let outcome = wait_for_outcome(&mut tasks, &panicked).await;
337        assert!(matches!(
338            outcome,
339            TaskOutcome::Panicked(message) if message.contains("task exploded")
340        ));
341        assert!(!tasks.joins.contains_key(&panicked.id()));
342    }
343
344    #[tokio::test]
345    async fn completion_and_cancellation_coalesce_wake_notifications() {
346        let mut tasks = AsyncTasks::default();
347        tasks.set_runtime(tokio::runtime::Handle::current());
348        let wake = Arc::new(tokio::sync::Notify::new());
349        tasks.set_waker(Arc::clone(&wake));
350
351        let handle = tasks.spawn(async { 1u8 });
352        tokio::time::timeout(Duration::from_secs(1), wake.notified())
353            .await
354            .expect("task completion should wake the dispatcher");
355        assert_eq!(
356            wait_for_outcome(&mut tasks, &handle).await,
357            TaskOutcome::Completed(1)
358        );
359
360        let mut cancelled = tasks.spawn(async {
361            tokio::time::sleep(Duration::from_secs(60)).await;
362        });
363        cancelled.cancel();
364        tokio::time::timeout(Duration::from_secs(1), wake.notified())
365            .await
366            .expect("task cancellation should wake the dispatcher");
367    }
368
369    #[tokio::test]
370    async fn thousands_of_task_ids_leave_no_join_entries() {
371        let mut tasks = AsyncTasks::default();
372        tasks.set_runtime(tokio::runtime::Handle::current());
373        let handles: Vec<_> = (0..2_000u32)
374            .map(|value| tasks.spawn(async move { value }))
375            .collect();
376
377        for handle in &handles {
378            assert!(matches!(
379                wait_for_outcome(&mut tasks, handle).await,
380                TaskOutcome::Completed(_)
381            ));
382        }
383        assert!(tasks.joins.is_empty());
384        assert!(tasks.results.is_empty());
385    }
386}