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}