Skip to main content

proxima_primitives/sync/
task.rs

1//! `proxima_primitives::sync::task` — runtime-agnostic task primitives shaped like
2//! `tokio::task`. Folded from the former `proxima-task` crate (Workstream
3//! F, RISC-dedup) so other proxima leaf crates (proxima-middleware,
4//! proxima-listeners-*, prime, etc.) reach it via the same crate as the
5//! rest of proxima's concurrency surface, without circling back through
6//! the top-level proxima crate.
7//!
8//! Surface:
9//!
10//! - [`yield_now`] — cooperative scheduling hook. Pure re-export of
11//!   `futures::task::yield_now`; runtime-agnostic.
12//! - [`JoinSet`] — under `runtime-tokio`, a thin newtype over
13//!   `tokio::task::JoinSet`. Without it (the default, tokio-free build),
14//!   a portable backing: one OS thread per spawned task running
15//!   `futures::executor::block_on`, with completions delivered through an
16//!   mpsc channel + `Notify` so `join_next` stays a non-blocking `.await`.
17//!   Real independent background progress without depending on the
18//!   caller's async runtime — the same functional contract as tokio's
19//!   `JoinSet`, minus true task abortion (an OS thread can't be forcibly
20//!   stopped; `abort_all` stops *waiting* on outstanding tasks instead).
21
22/// Cooperative yield. The returned future yields exactly once on
23/// first poll (`Pending` + waker-poke), then resolves on the next
24/// poll. Same semantic as `tokio::task::yield_now`, runtime-agnostic.
25pub fn yield_now() -> YieldNow {
26    YieldNow { yielded: false }
27}
28
29/// Future returned by [`yield_now`]. Named so callers can store it.
30#[derive(Debug)]
31pub struct YieldNow {
32    yielded: bool,
33}
34
35impl core::future::Future for YieldNow {
36    type Output = ();
37
38    fn poll(
39        mut self: core::pin::Pin<&mut Self>,
40        context: &mut core::task::Context<'_>,
41    ) -> core::task::Poll<()> {
42        if self.yielded {
43            core::task::Poll::Ready(())
44        } else {
45            self.yielded = true;
46            context.waker().wake_by_ref();
47            core::task::Poll::Pending
48        }
49    }
50}
51
52// `runtime-tokio` can be requested workspace-wide by a sibling crate
53// (see the root Cargo.toml's `proxima-primitives = { features = ["runtime-tokio"] }`)
54// independent of any `-p proxima-primitives` scoping; `not(loom)` keeps this
55// tokio-backed module out of the loom build regardless, since its
56// backing `dep:tokio` is itself `[target.'cfg(not(loom))'.dependencies]`.
57#[cfg(all(feature = "runtime-tokio", not(loom)))]
58pub use join_set::{JoinError, JoinSet};
59
60#[cfg(all(feature = "std", any(not(feature = "runtime-tokio"), loom)))]
61pub use portable_join_set::{AbortHandle, JoinError, JoinSet};
62
63#[cfg(all(feature = "runtime-tokio", not(loom)))]
64mod join_set {
65    //! Newtype over `tokio::task::JoinSet` that forwards `spawn` /
66    //! `join_next` / `abort_all` / `len` / `is_empty`. The whole point
67    //! is to give callers a `proxima::task::JoinSet` import so they
68    //! aren't reaching into `tokio::task::` directly — runtime
69    //! coupling stays inside this module.
70
71    use std::future::Future;
72
73    pub use tokio::task::JoinError;
74
75    /// Set of spawned tasks; pull completions in arrival order via
76    /// `join_next`. Matches `tokio::task::JoinSet`'s shape.
77    #[derive(Debug)]
78    pub struct JoinSet<T>(tokio::task::JoinSet<T>);
79
80    impl<T: 'static> JoinSet<T> {
81        /// New empty set.
82        #[must_use]
83        pub fn new() -> Self {
84            Self(tokio::task::JoinSet::new())
85        }
86
87        /// Spawn `future` into the set. Requires `T: Send + 'static`
88        /// and `future: Send + 'static`.
89        pub fn spawn<F>(&mut self, future: F) -> tokio::task::AbortHandle
90        where
91            F: Future<Output = T> + Send + 'static,
92            T: Send,
93        {
94            self.0.spawn(future)
95        }
96
97        /// Await the next task to complete. Returns `None` when the
98        /// set is empty.
99        pub async fn join_next(&mut self) -> Option<Result<T, JoinError>> {
100            self.0.join_next().await
101        }
102
103        /// Abort every spawned task. Pending `join_next` calls then
104        /// receive `Err(JoinError)` with `is_cancelled() == true`.
105        pub fn abort_all(&mut self) {
106            self.0.abort_all();
107        }
108
109        /// Number of tasks still in flight.
110        #[must_use]
111        pub fn len(&self) -> usize {
112            self.0.len()
113        }
114
115        /// `true` iff `len() == 0`.
116        #[must_use]
117        pub fn is_empty(&self) -> bool {
118            self.0.is_empty()
119        }
120    }
121
122    impl<T: 'static> Default for JoinSet<T> {
123        fn default() -> Self {
124            Self::new()
125        }
126    }
127
128    #[cfg(test)]
129    #[allow(clippy::unwrap_used, clippy::expect_used)]
130    mod tests {
131        use super::*;
132        use std::sync::Arc;
133        use std::sync::atomic::{AtomicUsize, Ordering};
134        use std::time::Duration;
135
136        #[proxima::test(runtime = "tokio")]
137        async fn spawn_then_join_next_returns_value() {
138            let mut set: JoinSet<u32> = JoinSet::new();
139            set.spawn(async { 42 });
140            let outcome = set.join_next().await.expect("one in flight");
141            assert_eq!(outcome.expect("not cancelled"), 42);
142            assert!(set.is_empty());
143        }
144
145        #[proxima::test(runtime = "tokio")]
146        async fn drop_aborts_running_tasks() {
147            let counter = Arc::new(AtomicUsize::new(0));
148            let observed_counts = {
149                let mut set: JoinSet<()> = JoinSet::new();
150                for _ in 0..3 {
151                    let counter = counter.clone();
152                    set.spawn(async move {
153                        loop {
154                            tokio::time::sleep(Duration::from_millis(1)).await;
155                            counter.fetch_add(1, Ordering::Release);
156                        }
157                    });
158                }
159                tokio::time::sleep(Duration::from_millis(20)).await;
160                let count_before_drop = counter.load(Ordering::Acquire);
161                drop(set);
162                tokio::time::sleep(Duration::from_millis(20)).await;
163                let count_after_grace = counter.load(Ordering::Acquire);
164                (count_before_drop, count_after_grace)
165            };
166            let (before, after) = observed_counts;
167            // counter incremented before drop; after drop + grace,
168            // counter should not be growing further (allow +5 jitter
169            // for whatever last sleeps were already-pending)
170            assert!(before > 0, "tasks should run before drop");
171            assert!(
172                after <= before + 5,
173                "tasks should stop within grace; before={before} after={after}"
174            );
175        }
176    }
177}
178
179#[cfg(all(feature = "std", any(not(feature = "runtime-tokio"), loom)))]
180mod portable_join_set {
181    //! tokio-free `JoinSet`: one OS thread per spawned task running
182    //! `futures::executor::block_on(future)`. Completions land in an mpsc
183    //! channel; `Notify` (event-listener backed) wakes `join_next` without
184    //! ever blocking the caller's executor thread. Cooperative sources
185    //! (the only kind `ProducerLifecycle` spawns) already exit on their own
186    //! once their `Signal` fires, so the OS thread naturally winds down —
187    //! `abort_all` only stops *this side* from waiting on stragglers.
188
189    use std::future::Future;
190    use std::panic::{AssertUnwindSafe, catch_unwind};
191    use std::sync::Arc;
192    use std::sync::mpsc;
193
194    use crate::sync::notify::Notify;
195
196    /// Mirrors `tokio::task::JoinError`'s query surface — the only part
197    /// `ProducerLifecycle` reads.
198    #[derive(Debug)]
199    pub struct JoinError {
200        panicked: bool,
201        cancelled: bool,
202    }
203
204    impl JoinError {
205        #[must_use]
206        pub fn is_panic(&self) -> bool {
207            self.panicked
208        }
209
210        #[must_use]
211        pub fn is_cancelled(&self) -> bool {
212            self.cancelled
213        }
214    }
215
216    impl core::fmt::Display for JoinError {
217        fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
218            if self.panicked {
219                write!(formatter, "task panicked")
220            } else if self.cancelled {
221                write!(formatter, "task aborted")
222            } else {
223                write!(formatter, "task join error")
224            }
225        }
226    }
227
228    impl std::error::Error for JoinError {}
229
230    /// No-op marker kept for call-site parity with `tokio::task::AbortHandle`
231    /// — an OS thread cannot be forcibly stopped from the outside.
232    #[derive(Debug, Clone, Copy)]
233    pub struct AbortHandle;
234
235    /// Tokio-free task set. See module docs for the execution model.
236    pub struct JoinSet<T> {
237        sender: mpsc::Sender<Result<T, JoinError>>,
238        receiver: mpsc::Receiver<Result<T, JoinError>>,
239        notify: Arc<Notify>,
240        outstanding: usize,
241    }
242
243    impl<T: Send + 'static> JoinSet<T> {
244        /// New empty set.
245        #[must_use]
246        pub fn new() -> Self {
247            let (sender, receiver) = mpsc::channel();
248            Self {
249                sender,
250                receiver,
251                notify: Arc::new(Notify::new()),
252                outstanding: 0,
253            }
254        }
255
256        /// Spawn `future` onto its own OS thread. Requires `T: Send +
257        /// 'static` and `future: Send + 'static` — same bound as tokio's
258        /// `JoinSet::spawn`.
259        pub fn spawn<F>(&mut self, future: F) -> AbortHandle
260        where
261            F: Future<Output = T> + Send + 'static,
262        {
263            self.outstanding += 1;
264            let sender = self.sender.clone();
265            let notify = self.notify.clone();
266            std::thread::spawn(move || {
267                let outcome =
268                    catch_unwind(AssertUnwindSafe(|| futures::executor::block_on(future)));
269                let result = match outcome {
270                    Ok(value) => Ok(value),
271                    Err(_) => Err(JoinError {
272                        panicked: true,
273                        cancelled: false,
274                    }),
275                };
276                // Send before notify: the channel's happens-before edge is
277                // what guarantees a woken `join_next` observes this value.
278                let _ = sender.send(result);
279                notify.notify_one();
280            });
281            AbortHandle
282        }
283
284        /// Await the next task to complete. Returns `None` once every
285        /// spawned task has been reaped.
286        pub async fn join_next(&mut self) -> Option<Result<T, JoinError>> {
287            loop {
288                if self.outstanding == 0 {
289                    return None;
290                }
291                match self.receiver.try_recv() {
292                    Ok(result) => {
293                        self.outstanding -= 1;
294                        return Some(result);
295                    }
296                    Err(mpsc::TryRecvError::Empty) => self.notify.notified().await,
297                    Err(mpsc::TryRecvError::Disconnected) => {
298                        self.outstanding = 0;
299                        return None;
300                    }
301                }
302            }
303        }
304
305        /// Stop waiting on outstanding tasks; each still-running OS thread
306        /// keeps executing to completion in the background (detached) since
307        /// it cannot be forcibly stopped, but subsequent `join_next` calls
308        /// report the remainder as cancelled instead of blocking on them.
309        pub fn abort_all(&mut self) {
310            while self.outstanding > 0 {
311                self.outstanding -= 1;
312                let _ = self.sender.send(Err(JoinError {
313                    panicked: false,
314                    cancelled: true,
315                }));
316            }
317            self.notify.notify_waiters();
318        }
319
320        /// Number of tasks still in flight.
321        #[must_use]
322        pub fn len(&self) -> usize {
323            self.outstanding
324        }
325
326        /// `true` iff `len() == 0`.
327        #[must_use]
328        pub fn is_empty(&self) -> bool {
329            self.outstanding == 0
330        }
331    }
332
333    impl<T: Send + 'static> Default for JoinSet<T> {
334        fn default() -> Self {
335            Self::new()
336        }
337    }
338}