epics_libcom_rs/runtime/background/future_exec.rs
1//! Runtime-free future executor over the callback pool — the RTEMS backend for
2//! [`crate::runtime::task::spawn`] / [`crate::runtime::task::spawn_blocking`]
3//! (decision A2, increment W3b).
4//!
5//! # Model — cooperative, not worker-per-future
6//!
7//! A hosted build runs a spawned async *tail* as a tokio task. RTEMS has no
8//! tokio runtime, so this module runs each spawned future as a **task object
9//! multiplexed over the [`CallbackPool`](super::callback_executor::CallbackPool)**:
10//!
11//! 1. `spawn` builds a `Task` and pushes it onto its band's ring.
12//! 2. A band worker pops it and polls it **once**.
13//! 3. `Ready` → the outcome is published and the task is done.
14//! 4. `Pending` → the worker **releases the task and moves on**. The waker
15//! handed to the poll is the task itself: waking it pushes the task back
16//! onto the ring (at the tail), where some worker picks it up and polls it
17//! again.
18//!
19//! So a band's workers are held only for the duration of a single poll, and a
20//! bounded worker set multiplexes an unbounded number of mostly-idle tails. All
21//! primitives the future awaits must still be runtime-agnostic (`tokio::sync`
22//! locks/channels/notifies, [`super::timer_sleep`]) — nothing here drives a
23//! reactor or a timer wheel — which is precisely the A2 precondition.
24//!
25//! This replaces the original design, where a worker ran
26//! `park_on_interruptible` for
27//! the *whole life* of the future and stayed parked between polls. That model
28//! had a structural defect: N concurrent long-lived tails exhausted the band's
29//! N workers, after which every further task on that band starved until one
30//! finished. Memory note `rtems-exec-worker-per-future-fragility`; it mattered
31//! more once PVA started sharing this backend.
32//!
33//! ## Task state machine
34//!
35//! One [`AtomicU8`] is the single owner of "may this task be polled, and by
36//! whom":
37//!
38//! | State | Meaning | Who leaves it |
39//! |---|---|---|
40//! | `IDLE` | not queued, not running — waiting for a wake | a waker, or an abort |
41//! | `SCHEDULED` | sitting on a band ring | the worker that pops it |
42//! | `RUNNING` | being polled right now | the polling worker |
43//! | `RUNNING_NOTIFIED` | being polled, and a wake landed mid-poll | the polling worker (re-enqueues) |
44//! | `DONE` | terminal | nobody |
45//!
46//! `RUNNING_NOTIFIED` is what makes a wake that races the poll safe: it is
47//! never dropped, it is deferred to the end of the current poll and turned into
48//! a re-enqueue. A wake arriving in any other state either enqueues (`IDLE`) or
49//! is redundant (`SCHEDULED` — already queued; `DONE` — nothing to run).
50//!
51//! ## Abort
52//!
53//! [`JoinFuture::abort`] / [`AbortHandle::abort`] latch a flag and then
54//! **schedule** the task, so an idle task is polled once more and observes the
55//! flag at the top of that poll — before the future is polled again. That is
56//! the same "cancel is observed at the next suspension point" contract the
57//! previous park-driver had (there, the abort unparked the parked worker); only
58//! the wake-up mechanism changed.
59//!
60//! ## Handle surface
61//!
62//! Unchanged. [`JoinFuture<T>`] mirrors the subset of `tokio::task::JoinHandle`
63//! the CA/PVA call sites actually use (see the W3b call-site map): `impl
64//! Future<Output = Result<T, JoinError>>`, [`abort`](JoinFuture::abort),
65//! [`is_finished`](JoinFuture::is_finished), and
66//! [`abort_handle`](JoinFuture::abort_handle) returning a non-generic
67//! [`AbortHandle`] with [`abort`](AbortHandle::abort) /
68//! [`is_finished`](AbortHandle::is_finished). [`JoinError`] mirrors only
69//! [`is_cancelled`](JoinError::is_cancelled) — the one `JoinError` method any
70//! call site consumes.
71//!
72//! ## Every handle resolves
73//!
74//! `Shared::finalize` is the single owner of "this task has an outcome", and
75//! it is idempotent. Three paths reach it, covering every way a task can stop
76//! existing: the poll produced `Ready`/cancel/panic; the queued entry was
77//! dropped without ever running (ring full, or the band shut down under it);
78//! or the task became unreachable — no queue entry and no live waker — and its
79//! `Drop` ran. A [`JoinFuture`] therefore never strands.
80//!
81//! ## Panic isolation
82//!
83//! C `callbackTask` (`callback.c:210-235`, cited in
84//! [`super::callback_executor`]) is a bare drain loop: it calls each callback
85//! and loops, with no exception machinery (C has none). A Rust callback *can*
86//! unwind, and an unwind out of the worker closure would tear down the band's
87//! worker thread — breaking that drain-loop invariant. So each poll is run
88//! under [`catch_unwind`]: a panicking task is reported as a panicked
89//! [`JoinError`] and the worker keeps draining, preserving the C loop's
90//! "one callback never stops the worker" property.
91
92use std::future::Future;
93use std::panic::{AssertUnwindSafe, catch_unwind};
94use std::pin::Pin;
95use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
96use std::sync::{Arc, Mutex, Weak};
97use std::task::{Context, Poll, Wake, Waker};
98
99use super::callback_executor::{Callback, CallbackHandle, CallbackPriority};
100
101/// Default band for a general spawned tail. C routes general deferred work
102/// through `callbackRequest` at `priorityMedium` (`callback.h:42`) — the middle
103/// of the three bands (`callback.h:41-43`) — so a spawned async tail lands
104/// there unless a caller picks another band.
105pub const DEFAULT_SPAWN_PRIORITY: CallbackPriority = CallbackPriority::Medium;
106
107// --- Task states (see the module docs' state table) -------------------------
108
109/// Not queued and not running; only a wake or an abort moves it on.
110const IDLE: u8 = 0;
111/// Sitting on a band ring, waiting for a worker to pop it.
112const SCHEDULED: u8 = 1;
113/// Being polled right now by a band worker.
114const RUNNING: u8 = 2;
115/// Being polled, and a wake landed during the poll — re-enqueue when it ends.
116const RUNNING_NOTIFIED: u8 = 3;
117/// Terminal: the outcome has been (or is being) published.
118const DONE: u8 = 4;
119
120/// Why awaiting a [`JoinFuture`] yielded an error instead of the task output —
121/// the seam-owned mirror of `tokio::task::JoinError`.
122///
123/// Only [`is_cancelled`](Self::is_cancelled) is exposed: it is the one
124/// `JoinError` method any seam call site consumes (the W3b map shows
125/// `is_cancelled()` in ca/pva shutdown paths; no site calls `is_panic()` /
126/// `into_panic()`).
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct JoinError {
129 kind: JoinErrorKind,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133enum JoinErrorKind {
134 /// The task was [`abort`](JoinFuture::abort)ed before it completed.
135 Cancelled,
136 /// The task's future (or blocking closure) panicked; the worker survived.
137 Panicked,
138}
139
140impl JoinError {
141 fn cancelled() -> Self {
142 JoinError {
143 kind: JoinErrorKind::Cancelled,
144 }
145 }
146
147 fn panicked() -> Self {
148 JoinError {
149 kind: JoinErrorKind::Panicked,
150 }
151 }
152
153 /// `true` when the task was aborted before completing — mirrors
154 /// `tokio::task::JoinError::is_cancelled`. A panicked task returns `false`
155 /// here (as tokio does).
156 pub fn is_cancelled(&self) -> bool {
157 matches!(self.kind, JoinErrorKind::Cancelled)
158 }
159}
160
161impl std::fmt::Display for JoinError {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 match self.kind {
164 JoinErrorKind::Cancelled => f.write_str("task was cancelled"),
165 JoinErrorKind::Panicked => f.write_str("task panicked"),
166 }
167 }
168}
169
170impl std::error::Error for JoinError {}
171
172/// The non-generic half of a task, so [`AbortHandle`] can be non-generic —
173/// exactly like `tokio::task::AbortHandle`, which ca/pva store in
174/// `Vec<(_, AbortHandle)>` fields that name no task type.
175trait Schedulable: Send + Sync {
176 /// Push the task onto its band ring if it is not already queued or running.
177 fn schedule(self: Arc<Self>);
178}
179
180/// Non-generic control block shared by a [`JoinFuture`] and every
181/// [`AbortHandle`] cloned from it.
182struct Control {
183 /// Set by [`AbortHandle::abort`] / [`JoinFuture::abort`]; read at the top of
184 /// every poll.
185 abort: AtomicBool,
186 /// Set once the task has produced its result (completed, cancelled, or
187 /// panicked). Backs `is_finished`.
188 finished: AtomicBool,
189 /// The task, for waking it so it can observe an abort.
190 ///
191 /// **`Weak` on purpose.** A strong reference here would close the cycle
192 /// task → shared → control → task and leak every task that ends without
193 /// running. `Weak` costs nothing: the task is alive exactly while it can
194 /// still run (a queue entry or a registered waker holds it), and if it is
195 /// *not* alive its `Drop` has already resolved the handle as cancelled — so
196 /// an abort that finds a dead `Weak` still leaves the joiner with
197 /// `is_cancelled()`.
198 task: Mutex<Option<Weak<dyn Schedulable>>>,
199}
200
201impl Control {
202 fn new() -> Arc<Self> {
203 Arc::new(Control {
204 abort: AtomicBool::new(false),
205 finished: AtomicBool::new(false),
206 task: Mutex::new(None),
207 })
208 }
209}
210
211/// Request cancellation on a control block: latch the abort flag, then schedule
212/// the task so it is polled once more and observes the flag at the top of that
213/// poll. A no-op once the task has finished (a `DONE` task ignores schedules)
214/// or once it is gone (its `Drop` already finalized it as cancelled).
215fn request_abort(control: &Control) {
216 control.abort.store(true, Ordering::Release);
217 // Clone out from under the lock: `schedule` reaches into the callback pool,
218 // and no pool operation may run while a control lock is held.
219 let task = control.task.lock().unwrap().clone();
220 if let Some(task) = task.and_then(|w| w.upgrade()) {
221 task.schedule();
222 }
223}
224
225/// Generic result slot plus the joiner's waker.
226struct Slot<T> {
227 /// The task's outcome, taken by the first [`JoinFuture::poll`] that sees it.
228 result: Option<Result<T, JoinError>>,
229 /// Whether an outcome has ever been published. Distinct from
230 /// `result.is_some()`, which goes back to `None` once the joiner takes it —
231 /// this is what makes [`Shared::finalize`] idempotent.
232 finalized: bool,
233 /// Waker of the task awaiting this [`JoinFuture`], if it polled before the
234 /// task finished.
235 join_waker: Option<Waker>,
236}
237
238struct Shared<T> {
239 control: Arc<Control>,
240 slot: Mutex<Slot<T>>,
241}
242
243impl<T> Shared<T> {
244 fn new() -> Arc<Self> {
245 Arc::new(Shared {
246 control: Control::new(),
247 slot: Mutex::new(Slot {
248 result: None,
249 finalized: false,
250 join_waker: None,
251 }),
252 })
253 }
254
255 /// Publish the task's outcome and wake any joiner — **the single owner of
256 /// the "task has an outcome" transition**, and idempotent: the first caller
257 /// wins and every later one is a no-op.
258 ///
259 /// Sets the result under the slot lock, then the `finished` flag (still
260 /// under the lock, so a concurrent `is_finished()` never observes
261 /// `finished` before the result is visible to a `poll`), then wakes outside
262 /// it — waking may run arbitrary code, including a re-entrant spawn.
263 fn finalize(&self, result: Result<T, JoinError>) {
264 let waker = {
265 let mut slot = self.slot.lock().unwrap();
266 if slot.finalized {
267 return;
268 }
269 slot.finalized = true;
270 slot.result = Some(result);
271 let waker = slot.join_waker.take();
272 self.control.finished.store(true, Ordering::Release);
273 waker
274 };
275 if let Some(w) = waker {
276 w.wake();
277 }
278 }
279}
280
281/// A spawned future plus everything needed to re-schedule it: the executor's
282/// unit of work, and the [`Waker`] handed to its own polls.
283struct Task<T> {
284 /// The state machine (see the module docs).
285 state: AtomicU8,
286 /// The future, taken out for the duration of a poll and put back on
287 /// `Pending`. `None` means "consumed" — completed, cancelled, or panicked.
288 future: Mutex<Option<Pin<Box<dyn Future<Output = T> + Send>>>>,
289 shared: Arc<Shared<T>>,
290 /// Where to push ourselves on wake.
291 callbacks: CallbackHandle,
292 priority: CallbackPriority,
293 /// Test-only: how many times this task has been pushed onto a band ring.
294 /// Backs the "a synchronously-completing future never round-trips the
295 /// queue" boundary test. Not compiled into a production build.
296 #[cfg(test)]
297 enqueues: std::sync::atomic::AtomicUsize,
298}
299
300impl<T: Send + 'static> Task<T> {
301 /// Push this task onto its band ring. The caller must have just claimed the
302 /// `SCHEDULED` state, so exactly one ring entry exists per task at a time.
303 fn enqueue(self: &Arc<Self>) {
304 #[cfg(test)]
305 self.enqueues.fetch_add(1, Ordering::Relaxed);
306
307 // `Entry` finalizes the task if it is dropped without running — see its
308 // `Drop`. Both `request` failure modes drop the callback, so the ring
309 // rejecting us and the band shutting down under us are both covered.
310 let mut entry = Entry {
311 task: Some(Arc::clone(self)),
312 };
313 let cb: Callback = Box::new(move || entry.run());
314 if self.callbacks.request(self.priority, cb).is_err() {
315 self.state.store(DONE, Ordering::Release);
316 tracing::error!(
317 target: "epics_base_rs::runtime::future_exec",
318 "spawn_future: callback ring full; task dropped, handle resolves cancelled"
319 );
320 }
321 }
322
323 /// Poll the task once on the calling worker, then either publish its
324 /// outcome or release the worker.
325 fn run(self: Arc<Self>) {
326 // SCHEDULED → RUNNING. Anything else means the task was finalized out
327 // from under this ring entry; there is nothing left to poll.
328 if self
329 .state
330 .compare_exchange(SCHEDULED, RUNNING, Ordering::AcqRel, Ordering::Acquire)
331 .is_err()
332 {
333 return;
334 }
335
336 let Some(mut fut) = self.future.lock().unwrap().take() else {
337 // Already consumed by a terminal path.
338 self.state.store(DONE, Ordering::Release);
339 return;
340 };
341
342 // Cancel is checked here, before the poll — the same point the previous
343 // park-driver checked it, so "the task is dropped at its next
344 // suspension point" is unchanged. Dropping `fut` here runs its
345 // destructors, exactly as a cancelled tokio task's does.
346 if self.shared.control.abort.load(Ordering::Acquire) {
347 drop(fut);
348 self.state.store(DONE, Ordering::Release);
349 self.shared.finalize(Err(JoinError::cancelled()));
350 return;
351 }
352
353 // The waker IS the task: waking re-enqueues it (module docs).
354 let waker = Waker::from(Arc::clone(&self));
355 // callback.c:210-235 drain-loop invariant: a panicking future must not
356 // tear down the band worker.
357 let polled = catch_unwind(AssertUnwindSafe(|| {
358 let mut cx = Context::from_waker(&waker);
359 fut.as_mut().poll(&mut cx)
360 }));
361
362 match polled {
363 Ok(Poll::Ready(value)) => {
364 drop(fut);
365 self.state.store(DONE, Ordering::Release);
366 self.shared.finalize(Ok(value));
367 }
368 Err(_panic) => {
369 drop(fut);
370 self.state.store(DONE, Ordering::Release);
371 self.shared.finalize(Err(JoinError::panicked()));
372 }
373 Ok(Poll::Pending) => {
374 // Put the future back BEFORE announcing we are schedulable
375 // again, or the worker that picks us up next could find an
376 // empty slot.
377 *self.future.lock().unwrap() = Some(fut);
378 if self
379 .state
380 .compare_exchange(RUNNING, IDLE, Ordering::AcqRel, Ordering::Acquire)
381 .is_err()
382 {
383 // RUNNING_NOTIFIED: a wake (or an abort) landed mid-poll.
384 // It was deliberately not enqueued then — that is this
385 // path's job, and re-enqueueing at the tail is what keeps a
386 // self-waking task from monopolising the worker.
387 self.state.store(SCHEDULED, Ordering::Release);
388 self.enqueue();
389 }
390 }
391 }
392 }
393}
394
395impl<T: Send + 'static> Schedulable for Task<T> {
396 fn schedule(self: Arc<Self>) {
397 loop {
398 match self.state.load(Ordering::Acquire) {
399 IDLE => {
400 if self
401 .state
402 .compare_exchange(IDLE, SCHEDULED, Ordering::AcqRel, Ordering::Acquire)
403 .is_ok()
404 {
405 self.enqueue();
406 return;
407 }
408 }
409 RUNNING => {
410 // Defer to the poll that is in flight — it re-enqueues.
411 if self
412 .state
413 .compare_exchange(
414 RUNNING,
415 RUNNING_NOTIFIED,
416 Ordering::AcqRel,
417 Ordering::Acquire,
418 )
419 .is_ok()
420 {
421 return;
422 }
423 }
424 // SCHEDULED: already on a ring. RUNNING_NOTIFIED: already
425 // deferred. DONE: nothing to run. All redundant.
426 _ => return,
427 }
428 }
429 }
430}
431
432impl<T: Send + 'static> Wake for Task<T> {
433 fn wake(self: Arc<Self>) {
434 Schedulable::schedule(self);
435 }
436
437 fn wake_by_ref(self: &Arc<Self>) {
438 Schedulable::schedule(Arc::clone(self));
439 }
440}
441
442impl<T> Drop for Task<T> {
443 /// The task became unreachable — no ring entry, no live waker — so nothing
444 /// will ever poll it again. Resolve the joiner rather than strand it.
445 /// A no-op on the normal paths, where `finalize` has already run.
446 fn drop(&mut self) {
447 self.shared.finalize(Err(JoinError::cancelled()));
448 }
449}
450
451/// One ring entry for a task, with the "ran or was dropped" bookkeeping.
452///
453/// A [`Callback`] is a `FnOnce` that the pool may drop instead of calling — on
454/// a full ring, or when the band shuts down (`callback.c:237-284` semantics,
455/// see [`super::callback_executor::CallbackHandle::request`]). Either way this
456/// task's only scheduled run is gone and its state is stuck at `SCHEDULED`, so
457/// no later wake would re-enqueue it. `Drop` closes that: an entry that is
458/// dropped un-run finalizes its task as cancelled.
459struct Entry<T> {
460 task: Option<Arc<Task<T>>>,
461}
462
463impl<T: Send + 'static> Entry<T> {
464 fn run(&mut self) {
465 if let Some(task) = self.task.take() {
466 task.run();
467 }
468 }
469}
470
471impl<T> Drop for Entry<T> {
472 fn drop(&mut self) {
473 if let Some(task) = self.task.take() {
474 task.state.store(DONE, Ordering::Release);
475 // Drop the future here rather than leaving it to the task's own
476 // `Drop`, which may not run promptly if a waker still holds a
477 // reference. Its destructors are part of the cancel.
478 let _ = task.future.lock().unwrap().take();
479 task.shared.finalize(Err(JoinError::cancelled()));
480 }
481 }
482}
483
484/// A handle over a spawned task — the RTEMS-side mirror of
485/// `tokio::task::JoinHandle`. `await` it for `Result<T, JoinError>`.
486pub struct JoinFuture<T> {
487 shared: Arc<Shared<T>>,
488}
489
490impl<T> JoinFuture<T> {
491 /// Request cancellation — mirrors `tokio::task::JoinHandle::abort`.
492 /// Best-effort: the task is dropped at its next suspension point (or before
493 /// its first poll if not yet started). A task already inside a synchronous
494 /// stretch runs to its next `await` before the cancel is observed.
495 pub fn abort(&self) {
496 request_abort(&self.shared.control);
497 }
498
499 /// `true` once the task has produced its result — mirrors
500 /// `tokio::task::JoinHandle::is_finished`.
501 pub fn is_finished(&self) -> bool {
502 self.shared.control.finished.load(Ordering::Acquire)
503 }
504
505 /// A non-generic abort handle for this task — mirrors
506 /// `tokio::task::JoinHandle::abort_handle`.
507 pub fn abort_handle(&self) -> AbortHandle {
508 AbortHandle {
509 control: Arc::clone(&self.shared.control),
510 }
511 }
512}
513
514impl<T> Future for JoinFuture<T> {
515 type Output = Result<T, JoinError>;
516
517 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
518 let mut slot = self.shared.slot.lock().unwrap();
519 match slot.result.take() {
520 Some(result) => Poll::Ready(result),
521 None => {
522 // Re-register the latest waker (the joiner may have moved).
523 slot.join_waker = Some(cx.waker().clone());
524 Poll::Pending
525 }
526 }
527 }
528}
529
530/// A cancellation handle detached from a [`JoinFuture`] — the mirror of
531/// `tokio::task::AbortHandle`. Cloneable and non-generic, so it can be stored in
532/// heterogeneous collections (as ca/pva store `AbortHandle`s).
533#[derive(Clone)]
534pub struct AbortHandle {
535 control: Arc<Control>,
536}
537
538// `tokio::task::AbortHandle` is `Debug`, and call sites rely on it: pva's
539// `server_native::tcp::AbortOnDrop` is a `#[derive(Debug)]` newtype over
540// whichever handle the seam selected. `Control` holds a `Mutex<Option<Weak<dyn
541// Schedulable>>>` that cannot be derived, so the mirror is written out — the
542// two flags are the whole observable state of the handle.
543impl std::fmt::Debug for AbortHandle {
544 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545 f.debug_struct("AbortHandle")
546 .field("aborted", &self.control.abort.load(Ordering::Relaxed))
547 .field("finished", &self.control.finished.load(Ordering::Acquire))
548 .finish()
549 }
550}
551
552impl AbortHandle {
553 /// Request cancellation — mirrors `tokio::task::AbortHandle::abort`.
554 pub fn abort(&self) {
555 request_abort(&self.control);
556 }
557
558 /// `true` once the task has finished — mirrors
559 /// `tokio::task::AbortHandle::is_finished`.
560 pub fn is_finished(&self) -> bool {
561 self.control.finished.load(Ordering::Acquire)
562 }
563}
564
565/// Spawn `fut` onto the callback pool behind `callbacks`, polled on
566/// `priority`-band workers. Returns immediately with a [`JoinFuture`].
567///
568/// The task occupies a worker only for the duration of each poll; between polls
569/// it holds nothing (module docs). If the band's ring is full (C
570/// `S_db_bufFull`) the task cannot be enqueued and the returned handle resolves
571/// as cancelled with an error logged — mirroring the fact that a tokio spawn
572/// never fails while still giving the caller a handle that resolves.
573pub fn spawn_future<F>(
574 callbacks: &CallbackHandle,
575 priority: CallbackPriority,
576 fut: F,
577) -> JoinFuture<F::Output>
578where
579 F: Future + Send + 'static,
580 F::Output: Send + 'static,
581{
582 spawn_task(callbacks, priority, fut).0
583}
584
585/// [`spawn_future`], also handing back the task itself so a test can inspect
586/// its scheduling. Dropping the extra `Arc` is harmless: the ring entry holds
587/// its own reference (and if the enqueue was rejected, dropping the last
588/// reference is exactly what resolves the handle).
589fn spawn_task<F>(
590 callbacks: &CallbackHandle,
591 priority: CallbackPriority,
592 fut: F,
593) -> (JoinFuture<F::Output>, Arc<Task<F::Output>>)
594where
595 F: Future + Send + 'static,
596 F::Output: Send + 'static,
597{
598 let shared = Shared::new();
599 let task = Arc::new(Task {
600 // Claimed immediately: the spawn itself is the first schedule.
601 state: AtomicU8::new(SCHEDULED),
602 future: Mutex::new(Some(Box::pin(fut))),
603 shared: Arc::clone(&shared),
604 callbacks: callbacks.clone(),
605 priority,
606 #[cfg(test)]
607 enqueues: std::sync::atomic::AtomicUsize::new(0),
608 });
609 *shared.control.task.lock().unwrap() = Some(Arc::downgrade(&task) as Weak<dyn Schedulable>);
610 task.enqueue();
611 (JoinFuture { shared }, task)
612}
613
614/// Run a blocking closure `f` on a callback-pool worker — the RTEMS backend for
615/// [`crate::runtime::task::spawn_blocking`]. Returns a [`JoinFuture`] resolving
616/// to `f`'s return value.
617///
618/// A blocking closure has no suspension point, so it cannot be aborted
619/// mid-run (as `tokio::task::spawn_blocking` also cannot); `abort` before it
620/// starts still cancels it. A panic is isolated exactly as for
621/// [`spawn_future`]. It holds its worker for its whole run — that is what
622/// "blocking" means, and unlike a spawned future there is nothing to yield at.
623pub fn spawn_blocking_on<F, R>(
624 callbacks: &CallbackHandle,
625 priority: CallbackPriority,
626 f: F,
627) -> JoinFuture<R>
628where
629 F: FnOnce() -> R + Send + 'static,
630 R: Send + 'static,
631{
632 let shared = Shared::new();
633 let task_shared = Arc::clone(&shared);
634 // Same "the pool may drop a callback instead of calling it" hazard as a
635 // spawned future's ring entry: resolve the handle either way.
636 let mut guard = FinalizeOnDrop {
637 shared: Some(Arc::clone(&shared)),
638 };
639 let callback: Callback = Box::new(move || {
640 guard.defuse();
641 // Honor an abort that landed before we started running.
642 if task_shared.control.abort.load(Ordering::Acquire) {
643 task_shared.finalize(Err(JoinError::cancelled()));
644 return;
645 }
646 let outcome = catch_unwind(AssertUnwindSafe(f));
647 let result = match outcome {
648 Ok(value) => Ok(value),
649 Err(_panic) => Err(JoinError::panicked()),
650 };
651 task_shared.finalize(result);
652 });
653
654 if callbacks.request(priority, callback).is_err() {
655 tracing::error!(
656 target: "epics_base_rs::runtime::future_exec",
657 "spawn_blocking_on: callback ring full; closure dropped, handle resolves cancelled"
658 );
659 }
660 JoinFuture { shared }
661}
662
663/// Resolves a handle as cancelled unless [`defuse`](Self::defuse)d — the
664/// blocking-closure counterpart of [`Entry`]'s `Drop`.
665struct FinalizeOnDrop<R> {
666 shared: Option<Arc<Shared<R>>>,
667}
668
669impl<R> FinalizeOnDrop<R> {
670 fn defuse(&mut self) {
671 self.shared = None;
672 }
673}
674
675impl<R> Drop for FinalizeOnDrop<R> {
676 fn drop(&mut self) {
677 if let Some(shared) = self.shared.take() {
678 shared.finalize(Err(JoinError::cancelled()));
679 }
680 }
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686 use crate::runtime::background::callback_executor::{
687 CallbackPool, DEFAULT_QUEUE_SIZE, DEFAULT_THREADS_PER_PRIORITY,
688 };
689 use crate::runtime::background::delayed_timer::DelayedTimer;
690 use crate::runtime::background::timer_sleep::sleep;
691 use crate::runtime::task::park_on_interruptible as drive;
692 use std::sync::mpsc;
693 use std::time::Duration;
694
695 const T: Duration = Duration::from_secs(5);
696
697 /// Block the test thread on a `JoinFuture`, returning its `Result`.
698 fn join<T>(jf: JoinFuture<T>) -> Result<T, JoinError> {
699 drive(jf, || false).expect("uncancelled join returned None")
700 }
701
702 // `JoinFuture` is single-await; several tests need both a method call and a
703 // join. Re-expose the shared handle by cloning the Arc so the test can do
704 // both without moving the handle into `join`.
705 fn jf_reborrow<T>(jf: &JoinFuture<T>) -> JoinFuture<T> {
706 JoinFuture {
707 shared: Arc::clone(&jf.shared),
708 }
709 }
710
711 /// Returns `Pending` `n` times, waking itself each time, then `Ready`.
712 /// Exercises the wake-during-poll (`RUNNING_NOTIFIED`) edge on every step.
713 struct YieldN(usize);
714
715 impl Future for YieldN {
716 type Output = ();
717
718 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
719 if self.0 == 0 {
720 return Poll::Ready(());
721 }
722 self.0 -= 1;
723 cx.waker().wake_by_ref();
724 Poll::Pending
725 }
726 }
727
728 // -- basic execution ----------------------------------------------------
729
730 #[test]
731 fn future_runs_to_completion() {
732 let pool = CallbackPool::new();
733 let jf = spawn_future(&pool.handle(), DEFAULT_SPAWN_PRIORITY, async { 42u32 });
734 assert_eq!(join(jf).unwrap(), 42);
735 }
736
737 #[test]
738 fn future_awaiting_cross_thread_primitive_completes() {
739 // The A2 precondition: a spawned tail that awaits a runtime-agnostic
740 // tokio::sync primitive, woken from ANOTHER thread, must complete with
741 // no tokio runtime present.
742 let pool = CallbackPool::new();
743 let (tx, rx) = tokio::sync::oneshot::channel::<u32>();
744 let jf = spawn_future(&pool.handle(), DEFAULT_SPAWN_PRIORITY, async move {
745 rx.await.unwrap()
746 });
747 // Send from the test thread after the task has already yielded.
748 std::thread::sleep(Duration::from_millis(20));
749 tx.send(7).unwrap();
750 assert_eq!(join(jf).unwrap(), 7);
751 }
752
753 #[test]
754 fn panic_in_task_does_not_kill_the_worker() {
755 // callback.c:210-235 drain-loop invariant: one bad callback must not
756 // stop the band. The panicked task reports a non-cancelled JoinError,
757 // and a subsequent task on the SAME pool still runs.
758 let pool = CallbackPool::new();
759
760 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async {
761 panic!("boom");
762 });
763 let err = join(jf).unwrap_err();
764 assert!(!err.is_cancelled(), "a panic is not a cancellation");
765
766 // Same pool, same band — the worker survived and drains the next task.
767 let jf2 = spawn_future(&pool.handle(), CallbackPriority::Medium, async { 99u32 });
768 assert_eq!(join(jf2).unwrap(), 99);
769 }
770
771 #[test]
772 fn panic_after_a_yield_is_isolated_too() {
773 // Boundary partner to the above: the panic happens on a LATER poll, so
774 // it unwinds out of a re-enqueued run rather than the first one.
775 let pool = CallbackPool::new();
776 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async {
777 YieldN(3).await;
778 panic!("late boom");
779 });
780 assert!(!join::<()>(jf).unwrap_err().is_cancelled());
781
782 let jf2 = spawn_future(&pool.handle(), CallbackPriority::Medium, async { 7u32 });
783 assert_eq!(join(jf2).unwrap(), 7);
784 }
785
786 // -- invariant 4: a synchronous completion never round-trips the queue ---
787
788 #[test]
789 fn synchronously_ready_future_is_enqueued_exactly_once() {
790 // Boundary: zero suspensions. The spawn itself is one enqueue; a task
791 // that is Ready on its first poll must add no more.
792 let pool = CallbackPool::new();
793 let (jf, task) = spawn_task(&pool.handle(), CallbackPriority::Medium, async { 1u8 });
794 assert_eq!(join(jf).unwrap(), 1);
795 assert_eq!(
796 task.enqueues.load(Ordering::Relaxed),
797 1,
798 "a future that completes on its first poll must not round-trip the ring"
799 );
800 }
801
802 #[test]
803 fn each_suspension_costs_exactly_one_re_enqueue() {
804 // The other side of the same boundary: N suspensions → N re-enqueues,
805 // i.e. the queue is used once per wake and never speculatively.
806 let pool = CallbackPool::new();
807 let (jf, task) = spawn_task(&pool.handle(), CallbackPriority::Medium, YieldN(3));
808 join(jf).unwrap();
809 assert_eq!(
810 task.enqueues.load(Ordering::Relaxed),
811 4,
812 "expected 1 spawn + 3 wakes"
813 );
814 }
815
816 // -- invariant 3: a released worker is reused; a woken task does not starve
817
818 #[test]
819 fn a_yielding_task_releases_the_worker_to_a_queued_task() {
820 // Single worker, two tasks. `slow` yields many times; `quick` is queued
821 // behind it. Under the old park-a-worker design `quick` could not run
822 // until `slow` finished, so the order would be slow-then-quick. With
823 // the worker released at every suspension, `quick` — already on the
824 // ring when `slow` first re-enqueues at the TAIL — runs first.
825 assert_eq!(DEFAULT_THREADS_PER_PRIORITY, 1);
826 let pool = CallbackPool::new();
827 let (tx, rx) = mpsc::channel::<&'static str>();
828
829 let tx_slow = tx.clone();
830 let slow = spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
831 YieldN(20).await;
832 tx_slow.send("slow").unwrap();
833 });
834 let quick = spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
835 tx.send("quick").unwrap();
836 });
837
838 assert_eq!(
839 rx.recv_timeout(T).unwrap(),
840 "quick",
841 "a suspended task must not hold the band's only worker"
842 );
843 assert_eq!(
844 rx.recv_timeout(T).unwrap(),
845 "slow",
846 "a repeatedly re-enqueued task must still make progress"
847 );
848 join(quick).unwrap();
849 join(slow).unwrap();
850 }
851
852 #[test]
853 fn many_self_waking_tasks_all_finish_on_one_worker() {
854 // No-starvation under contention: 8 tasks, each waking itself 25 times,
855 // multiplexed over a single worker. Tail re-enqueue makes this fair
856 // enough that every one of them terminates.
857 assert_eq!(DEFAULT_THREADS_PER_PRIORITY, 1);
858 let pool = CallbackPool::new();
859 let handles: Vec<_> = (0..8)
860 .map(|i| {
861 spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
862 YieldN(25).await;
863 i
864 })
865 })
866 .collect();
867 for (i, jf) in handles.into_iter().enumerate() {
868 assert_eq!(join(jf).unwrap(), i);
869 }
870 }
871
872 // -- invariant 1: the sleep-wake inline path ----------------------------
873
874 #[test]
875 fn spawned_future_awaiting_sleep_does_not_self_deadlock() {
876 // Regression for the sleep-wake self-deadlock (bug_pattern
877 // rtems-exec-sleep-wake-band-deadlock): a future SPAWNED ON THE POOL
878 // that awaits `timer_sleep::sleep` must complete. This is the exact
879 // `spawn(async { sleep().await })` shape that ODLY/SDLY async-record
880 // reprocessing uses on the RTEMS backend.
881 //
882 // Why the existing `timer_sleep` unit tests did NOT catch it: they
883 // `drive()` the Sleep on the TEST thread, leaving the pool's single
884 // Medium worker free to run the wake callback. Here the future runs on
885 // the pool worker (via `spawn_future`) — so with the old behavior, where
886 // the sleep-wake was `sink.request(Medium, ...)`, the wake queued behind
887 // the very worker parked on this future (one worker per band) and never
888 // ran. We must OBSERVE completion via a channel, NOT `join()`/`drive()`
889 // on the test thread, or we would re-introduce the free-worker escape
890 // hatch and stop reproducing the deadlock.
891 let pool = CallbackPool::new();
892 let timer = DelayedTimer::new(pool.handle());
893 let th = timer.handle();
894
895 let (done_tx, done_rx) = mpsc::channel::<()>();
896 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
897 sleep(&th, Duration::from_millis(50)).await;
898 done_tx.send(()).unwrap();
899 });
900
901 // With the fix (inline wake on the timer thread) the sleep fires and the
902 // future finishes well inside T. With the pre-fix band-dispatch wake this
903 // recv times out because the wake starves behind the parked worker.
904 done_rx.recv_timeout(T).expect(
905 "spawned future awaiting sleep deadlocked (wake starved behind its own worker)",
906 );
907 assert!(join(jf).is_ok());
908 }
909
910 #[test]
911 fn a_sleep_wake_needs_no_pool_worker() {
912 // The inline-wake guarantee, stated directly: pin the band's ONLY
913 // worker inside an unrelated blocking callback, and a sleeping task's
914 // wake must still land. If the wake needed a worker it would sit behind
915 // the pinned one and `fired` would stay false.
916 assert_eq!(DEFAULT_THREADS_PER_PRIORITY, 1);
917 let pool = CallbackPool::new();
918 let timer = DelayedTimer::new(pool.handle());
919
920 let (pinned_tx, pinned_rx) = mpsc::channel::<()>();
921 let (release_tx, release_rx) = mpsc::channel::<()>();
922 pool.request(
923 CallbackPriority::Medium,
924 Box::new(move || {
925 pinned_tx.send(()).unwrap();
926 release_rx.recv().unwrap();
927 }),
928 )
929 .unwrap();
930 pinned_rx.recv_timeout(T).unwrap(); // the sole Medium worker is busy
931
932 let (woke_tx, woke_rx) = mpsc::channel::<()>();
933 // Drive the Sleep on the TEST thread — no pool worker involved at all,
934 // so what this observes is purely "did the wake arrive".
935 let th = timer.handle();
936 let sleeper = std::thread::spawn(move || {
937 drive(sleep(&th, Duration::from_millis(30)), || false).unwrap();
938 woke_tx.send(()).unwrap();
939 });
940 woke_rx
941 .recv_timeout(T)
942 .expect("sleep wake did not arrive while the band's worker was pinned");
943
944 release_tx.send(()).unwrap();
945 sleeper.join().unwrap();
946 }
947
948 #[test]
949 fn three_sleeping_tails_share_one_worker() {
950 // The old failure mode, gone. DEFAULT_THREADS_PER_PRIORITY is 1, so
951 // under the park-a-worker-per-future design these three tails would
952 // have needed three workers: #1 would hold the only one for its whole
953 // sleep, and #2 and #3 could not even START until it finished.
954 //
955 // The assertion is structural, not a stopwatch: every task announces
956 // Started before it sleeps and Done after. All three Starteds must
957 // arrive before the first Done — which is only possible if each task
958 // handed the worker back at its suspension point.
959 assert_eq!(DEFAULT_THREADS_PER_PRIORITY, 1);
960 let pool = CallbackPool::with_config(DEFAULT_QUEUE_SIZE, DEFAULT_THREADS_PER_PRIORITY);
961 let timer = DelayedTimer::new(pool.handle());
962
963 #[derive(Debug, PartialEq, Eq)]
964 enum Ev {
965 Started,
966 Done(u8),
967 }
968
969 let (tx, rx) = mpsc::channel::<Ev>();
970 let handles: Vec<_> = (0..3u8)
971 .map(|i| {
972 let th = timer.handle();
973 let tx = tx.clone();
974 spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
975 tx.send(Ev::Started).unwrap();
976 sleep(&th, Duration::from_millis(120)).await;
977 tx.send(Ev::Done(i)).unwrap();
978 i
979 })
980 })
981 .collect();
982 drop(tx);
983
984 for n in 0..3 {
985 assert_eq!(
986 rx.recv_timeout(T).unwrap(),
987 Ev::Started,
988 "task {n} had not started before the first task finished — the \
989 worker was held across a suspension"
990 );
991 }
992 let mut done: Vec<u8> = (0..3)
993 .map(|_| match rx.recv_timeout(T).unwrap() {
994 Ev::Done(i) => i,
995 Ev::Started => panic!("only three tasks exist"),
996 })
997 .collect();
998 done.sort_unstable();
999 assert_eq!(done, vec![0, 1, 2], "all three tails must complete");
1000
1001 for (i, jf) in handles.into_iter().enumerate() {
1002 assert_eq!(join(jf).unwrap() as usize, i);
1003 }
1004 }
1005
1006 // -- invariant 2: abort ------------------------------------------------
1007
1008 #[test]
1009 fn abort_while_idle_cancels_cleanly() {
1010 // Boundary: the task is IDLE — parked between polls on a primitive that
1011 // will never fire, holding no worker. Nothing but the abort can wake
1012 // it, so this is what proves `request_abort` schedules.
1013 let pool = CallbackPool::new();
1014 let (_tx, rx) = tokio::sync::oneshot::channel::<()>();
1015 let (ran_tx, ran_rx) = mpsc::channel();
1016 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
1017 ran_tx.send(()).unwrap();
1018 let _ = rx.await;
1019 });
1020 // The task has run at least once and yielded.
1021 ran_rx.recv_timeout(T).unwrap();
1022 std::thread::sleep(Duration::from_millis(20));
1023
1024 jf.abort();
1025 let err = join(jf_reborrow(&jf)).unwrap_err();
1026 assert!(err.is_cancelled(), "aborted task must report cancelled");
1027 assert!(jf.is_finished());
1028 }
1029
1030 #[test]
1031 fn abort_before_the_first_poll_cancels_without_running() {
1032 // Boundary: the task is SCHEDULED but not yet RUNNING. Pin the band's
1033 // only worker so the task cannot start, abort it, then release. It must
1034 // resolve cancelled and its body must never have executed.
1035 assert_eq!(DEFAULT_THREADS_PER_PRIORITY, 1);
1036 let pool = CallbackPool::new();
1037 let (pinned_tx, pinned_rx) = mpsc::channel::<()>();
1038 let (release_tx, release_rx) = mpsc::channel::<()>();
1039 pool.request(
1040 CallbackPriority::Medium,
1041 Box::new(move || {
1042 pinned_tx.send(()).unwrap();
1043 release_rx.recv().unwrap();
1044 }),
1045 )
1046 .unwrap();
1047 pinned_rx.recv_timeout(T).unwrap();
1048
1049 let ran = Arc::new(AtomicBool::new(false));
1050 let flag = Arc::clone(&ran);
1051 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
1052 flag.store(true, Ordering::SeqCst);
1053 });
1054 jf.abort();
1055 release_tx.send(()).unwrap();
1056
1057 assert!(join(jf_reborrow(&jf)).unwrap_err().is_cancelled());
1058 assert!(
1059 !ran.load(Ordering::SeqCst),
1060 "an abort observed before the first poll must not run the future"
1061 );
1062 }
1063
1064 #[test]
1065 fn abort_during_a_poll_is_observed_at_the_next_one() {
1066 // Boundary: the abort lands while the task is RUNNING, i.e. inside a
1067 // synchronous stretch. tokio's contract (and the previous park-driver's)
1068 // is that it is observed at the NEXT suspension point, not mid-poll —
1069 // so the current poll finishes and the following one cancels.
1070 let pool = CallbackPool::new();
1071 let (in_poll_tx, in_poll_rx) = mpsc::channel::<()>();
1072 let (go_tx, go_rx) = mpsc::channel::<()>();
1073 let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1074 let counter = Arc::clone(&polls);
1075
1076 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
1077 counter.fetch_add(1, Ordering::SeqCst);
1078 // Synchronous stretch: tell the test we are inside the poll and
1079 // block here until it has aborted us.
1080 in_poll_tx.send(()).unwrap();
1081 go_rx.recv().unwrap();
1082 YieldN(1).await; // the suspension point the cancel is observed at
1083 counter.fetch_add(100, Ordering::SeqCst);
1084 });
1085
1086 in_poll_rx.recv_timeout(T).unwrap();
1087 jf.abort(); // lands while the task is RUNNING
1088 go_tx.send(()).unwrap();
1089
1090 assert!(join(jf_reborrow(&jf)).unwrap_err().is_cancelled());
1091 assert_eq!(
1092 polls.load(Ordering::SeqCst),
1093 1,
1094 "the code after the suspension point must not have run"
1095 );
1096 }
1097
1098 #[test]
1099 fn abort_handle_cancels_the_task() {
1100 let pool = CallbackPool::new();
1101 let (_tx, rx) = tokio::sync::oneshot::channel::<()>();
1102 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
1103 let _ = rx.await;
1104 });
1105 let ah = jf.abort_handle();
1106 std::thread::sleep(Duration::from_millis(20));
1107 assert!(!ah.is_finished());
1108 ah.abort();
1109 assert!(join(jf).unwrap_err().is_cancelled());
1110 assert!(ah.is_finished());
1111 }
1112
1113 #[test]
1114 fn abort_after_completion_does_not_rewrite_the_outcome() {
1115 // Boundary: DONE. `finalize` is idempotent, so a late abort must not
1116 // turn a delivered value into a cancellation.
1117 let pool = CallbackPool::new();
1118 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async { 5u32 });
1119 while !jf.is_finished() {
1120 std::thread::sleep(Duration::from_millis(2));
1121 }
1122 jf.abort();
1123 assert_eq!(join(jf).unwrap(), 5);
1124 }
1125
1126 // -- every handle resolves ---------------------------------------------
1127
1128 #[test]
1129 fn full_ring_resolves_the_handle_as_cancelled() {
1130 // Boundary: the spawn never reaches a worker at all. C `S_db_bufFull`
1131 // (callback.c:373) — the task is dropped, so the handle must resolve
1132 // rather than strand its joiner.
1133 let pool = CallbackPool::with_config(1, 1);
1134 let (pinned_tx, pinned_rx) = mpsc::channel::<()>();
1135 let (release_tx, release_rx) = mpsc::channel::<()>();
1136 pool.request(
1137 CallbackPriority::Medium,
1138 Box::new(move || {
1139 pinned_tx.send(()).unwrap();
1140 release_rx.recv().unwrap();
1141 }),
1142 )
1143 .unwrap();
1144 pinned_rx.recv_timeout(T).unwrap();
1145
1146 // Fill the single ring slot, then latch overflow.
1147 pool.request(CallbackPriority::Medium, Box::new(|| {}))
1148 .unwrap();
1149 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async { 1u32 });
1150 assert!(
1151 join(jf).unwrap_err().is_cancelled(),
1152 "a rejected spawn must resolve its handle, not strand it"
1153 );
1154
1155 release_tx.send(()).unwrap();
1156 }
1157
1158 #[test]
1159 fn spawn_blocking_returns_value_and_isolates_panic() {
1160 let pool = CallbackPool::new();
1161 let jf = spawn_blocking_on(&pool.handle(), CallbackPriority::Medium, || 123u32);
1162 assert_eq!(join(jf).unwrap(), 123);
1163
1164 let jf = spawn_blocking_on(&pool.handle(), CallbackPriority::Medium, || {
1165 panic!("blocking boom")
1166 });
1167 assert!(!join::<()>(jf).unwrap_err().is_cancelled());
1168
1169 // Worker survived the panic.
1170 let jf = spawn_blocking_on(&pool.handle(), CallbackPriority::Medium, || 5u32);
1171 assert_eq!(join(jf).unwrap(), 5);
1172 }
1173}