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::Reactor::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 (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` — which is what the band does with it once shut down. That is
309 // the only way an entry goes un-run: it holds no ring slot, so a full
310 // ring cannot turn a wake into the silent end of a long-lived task.
311 let mut entry = Entry {
312 task: Some(Arc::clone(self)),
313 };
314 self.callbacks
315 .schedule_task(self.priority, Box::new(move || entry.run()));
316 }
317
318 /// Poll the task once on the calling worker, then either publish its
319 /// outcome or release the worker.
320 fn run(self: Arc<Self>) {
321 // SCHEDULED → RUNNING. Anything else means the task was finalized out
322 // from under this ring entry; there is nothing left to poll.
323 if self
324 .state
325 .compare_exchange(SCHEDULED, RUNNING, Ordering::AcqRel, Ordering::Acquire)
326 .is_err()
327 {
328 return;
329 }
330
331 let Some(mut fut) = self.future.lock().unwrap().take() else {
332 // Already consumed by a terminal path.
333 self.state.store(DONE, Ordering::Release);
334 return;
335 };
336
337 // Cancel is checked here, before the poll — the same point the previous
338 // park-driver checked it, so "the task is dropped at its next
339 // suspension point" is unchanged. Dropping `fut` here runs its
340 // destructors, exactly as a cancelled tokio task's does.
341 if self.shared.control.abort.load(Ordering::Acquire) {
342 drop(fut);
343 self.state.store(DONE, Ordering::Release);
344 self.shared.finalize(Err(JoinError::cancelled()));
345 return;
346 }
347
348 // The waker IS the task: waking re-enqueues it (module docs).
349 let waker = Waker::from(Arc::clone(&self));
350 // callback.c:210-235 drain-loop invariant: a panicking future must not
351 // tear down the band worker.
352 let polled = catch_unwind(AssertUnwindSafe(|| {
353 let mut cx = Context::from_waker(&waker);
354 fut.as_mut().poll(&mut cx)
355 }));
356
357 match polled {
358 Ok(Poll::Ready(value)) => {
359 drop(fut);
360 self.state.store(DONE, Ordering::Release);
361 self.shared.finalize(Ok(value));
362 }
363 Err(_panic) => {
364 drop(fut);
365 self.state.store(DONE, Ordering::Release);
366 self.shared.finalize(Err(JoinError::panicked()));
367 }
368 Ok(Poll::Pending) => {
369 // Put the future back BEFORE announcing we are schedulable
370 // again, or the worker that picks us up next could find an
371 // empty slot.
372 *self.future.lock().unwrap() = Some(fut);
373 if self
374 .state
375 .compare_exchange(RUNNING, IDLE, Ordering::AcqRel, Ordering::Acquire)
376 .is_err()
377 {
378 // RUNNING_NOTIFIED: a wake (or an abort) landed mid-poll.
379 // It was deliberately not enqueued then — that is this
380 // path's job, and re-enqueueing at the tail is what keeps a
381 // self-waking task from monopolising the worker.
382 self.state.store(SCHEDULED, Ordering::Release);
383 self.enqueue();
384 }
385 }
386 }
387 }
388}
389
390impl<T: Send + 'static> Schedulable for Task<T> {
391 fn schedule(self: Arc<Self>) {
392 loop {
393 match self.state.load(Ordering::Acquire) {
394 IDLE => {
395 if self
396 .state
397 .compare_exchange(IDLE, SCHEDULED, Ordering::AcqRel, Ordering::Acquire)
398 .is_ok()
399 {
400 self.enqueue();
401 return;
402 }
403 }
404 RUNNING => {
405 // Defer to the poll that is in flight — it re-enqueues.
406 if self
407 .state
408 .compare_exchange(
409 RUNNING,
410 RUNNING_NOTIFIED,
411 Ordering::AcqRel,
412 Ordering::Acquire,
413 )
414 .is_ok()
415 {
416 return;
417 }
418 }
419 // SCHEDULED: already on a ring. RUNNING_NOTIFIED: already
420 // deferred. DONE: nothing to run. All redundant.
421 _ => return,
422 }
423 }
424 }
425}
426
427impl<T: Send + 'static> Wake for Task<T> {
428 fn wake(self: Arc<Self>) {
429 Schedulable::schedule(self);
430 }
431
432 fn wake_by_ref(self: &Arc<Self>) {
433 Schedulable::schedule(Arc::clone(self));
434 }
435}
436
437impl<T> Drop for Task<T> {
438 /// The task became unreachable — no ring entry, no live waker — so nothing
439 /// will ever poll it again. Resolve the joiner rather than strand it.
440 /// A no-op on the normal paths, where `finalize` has already run.
441 fn drop(&mut self) {
442 self.shared.finalize(Err(JoinError::cancelled()));
443 }
444}
445
446/// One run-queue entry for a task, with the "ran or was dropped" bookkeeping.
447///
448/// A [`Callback`] is a `FnOnce` that the pool drops instead of calling when the
449/// band shuts down (`callback.c:237-284` semantics). Then this
450/// task's only scheduled run is gone and its state is stuck at `SCHEDULED`, so
451/// no later wake would re-enqueue it. `Drop` closes that: an entry that is
452/// dropped un-run finalizes its task as cancelled.
453struct Entry<T> {
454 task: Option<Arc<Task<T>>>,
455}
456
457impl<T: Send + 'static> Entry<T> {
458 fn run(&mut self) {
459 if let Some(task) = self.task.take() {
460 task.run();
461 }
462 }
463}
464
465impl<T> Drop for Entry<T> {
466 fn drop(&mut self) {
467 if let Some(task) = self.task.take() {
468 task.state.store(DONE, Ordering::Release);
469 // Drop the future here rather than leaving it to the task's own
470 // `Drop`, which may not run promptly if a waker still holds a
471 // reference. Its destructors are part of the cancel.
472 let _ = task.future.lock().unwrap().take();
473 task.shared.finalize(Err(JoinError::cancelled()));
474 }
475 }
476}
477
478/// A handle over a spawned task — the RTEMS-side mirror of
479/// `tokio::task::JoinHandle`. `await` it for `Result<T, JoinError>`.
480pub struct JoinFuture<T> {
481 shared: Arc<Shared<T>>,
482}
483
484impl<T> JoinFuture<T> {
485 /// Request cancellation — mirrors `tokio::task::JoinHandle::abort`.
486 /// Best-effort: the task is dropped at its next suspension point (or before
487 /// its first poll if not yet started). A task already inside a synchronous
488 /// stretch runs to its next `await` before the cancel is observed.
489 pub fn abort(&self) {
490 request_abort(&self.shared.control);
491 }
492
493 /// `true` once the task has produced its result — mirrors
494 /// `tokio::task::JoinHandle::is_finished`.
495 pub fn is_finished(&self) -> bool {
496 self.shared.control.finished.load(Ordering::Acquire)
497 }
498
499 /// A non-generic abort handle for this task — mirrors
500 /// `tokio::task::JoinHandle::abort_handle`.
501 pub fn abort_handle(&self) -> AbortHandle {
502 AbortHandle {
503 control: Arc::clone(&self.shared.control),
504 }
505 }
506}
507
508impl<T> Future for JoinFuture<T> {
509 type Output = Result<T, JoinError>;
510
511 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
512 let mut slot = self.shared.slot.lock().unwrap();
513 match slot.result.take() {
514 Some(result) => Poll::Ready(result),
515 None => {
516 // Re-register the latest waker (the joiner may have moved).
517 slot.join_waker = Some(cx.waker().clone());
518 Poll::Pending
519 }
520 }
521 }
522}
523
524/// A cancellation handle detached from a [`JoinFuture`] — the mirror of
525/// `tokio::task::AbortHandle`. Cloneable and non-generic, so it can be stored in
526/// heterogeneous collections (as ca/pva store `AbortHandle`s).
527#[derive(Clone)]
528pub struct AbortHandle {
529 control: Arc<Control>,
530}
531
532// `tokio::task::AbortHandle` is `Debug`, and call sites rely on it: pva's
533// `server_native::tcp::AbortOnDrop` is a `#[derive(Debug)]` newtype over
534// whichever handle the seam selected. `Control` holds a `Mutex<Option<Weak<dyn
535// Schedulable>>>` that cannot be derived, so the mirror is written out — the
536// two flags are the whole observable state of the handle.
537impl std::fmt::Debug for AbortHandle {
538 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
539 f.debug_struct("AbortHandle")
540 .field("aborted", &self.control.abort.load(Ordering::Relaxed))
541 .field("finished", &self.control.finished.load(Ordering::Acquire))
542 .finish()
543 }
544}
545
546impl AbortHandle {
547 /// Request cancellation — mirrors `tokio::task::AbortHandle::abort`.
548 pub fn abort(&self) {
549 request_abort(&self.control);
550 }
551
552 /// `true` once the task has finished — mirrors
553 /// `tokio::task::AbortHandle::is_finished`.
554 pub fn is_finished(&self) -> bool {
555 self.control.finished.load(Ordering::Acquire)
556 }
557}
558
559/// Spawn `fut` onto the callback pool behind `callbacks`, polled on
560/// `priority`-band workers. Returns immediately with a [`JoinFuture`].
561///
562/// The task occupies a worker only for the duration of each poll; between polls
563/// it holds nothing (module docs). If the band's ring is full (C
564/// `S_db_bufFull`) the task cannot be enqueued and the returned handle resolves
565/// as cancelled with an error logged — mirroring the fact that a tokio spawn
566/// never fails while still giving the caller a handle that resolves.
567pub fn spawn_future<F>(
568 callbacks: &CallbackHandle,
569 priority: CallbackPriority,
570 fut: F,
571) -> JoinFuture<F::Output>
572where
573 F: Future + Send + 'static,
574 F::Output: Send + 'static,
575{
576 spawn_task(callbacks, priority, fut).0
577}
578
579/// [`spawn_future`], also handing back the task itself so a test can inspect
580/// its scheduling. Dropping the extra `Arc` is harmless: the ring entry holds
581/// its own reference (and if the enqueue was rejected, dropping the last
582/// reference is exactly what resolves the handle).
583fn spawn_task<F>(
584 callbacks: &CallbackHandle,
585 priority: CallbackPriority,
586 fut: F,
587) -> (JoinFuture<F::Output>, Arc<Task<F::Output>>)
588where
589 F: Future + Send + 'static,
590 F::Output: Send + 'static,
591{
592 let shared = Shared::new();
593 let task = Arc::new(Task {
594 // Claimed immediately: the spawn itself is the first schedule.
595 state: AtomicU8::new(SCHEDULED),
596 future: Mutex::new(Some(Box::pin(fut))),
597 shared: Arc::clone(&shared),
598 callbacks: callbacks.clone(),
599 priority,
600 #[cfg(test)]
601 enqueues: std::sync::atomic::AtomicUsize::new(0),
602 });
603 *shared.control.task.lock().unwrap() = Some(Arc::downgrade(&task) as Weak<dyn Schedulable>);
604 task.enqueue();
605 (JoinFuture { shared }, task)
606}
607
608/// Run a blocking closure `f` on a callback-pool worker — the RTEMS backend for
609/// [`crate::runtime::task::spawn_blocking`]. Returns a [`JoinFuture`] resolving
610/// to `f`'s return value.
611///
612/// A blocking closure has no suspension point, so it cannot be aborted
613/// mid-run (as `tokio::task::spawn_blocking` also cannot); `abort` before it
614/// starts still cancels it. A panic is isolated exactly as for
615/// [`spawn_future`]. It holds its worker for its whole run — that is what
616/// "blocking" means, and unlike a spawned future there is nothing to yield at.
617pub fn spawn_blocking_on<F, R>(
618 callbacks: &CallbackHandle,
619 priority: CallbackPriority,
620 f: F,
621) -> JoinFuture<R>
622where
623 F: FnOnce() -> R + Send + 'static,
624 R: Send + 'static,
625{
626 let shared = Shared::new();
627 let task_shared = Arc::clone(&shared);
628 // Same "the pool may drop a callback instead of calling it" hazard as a
629 // spawned future's ring entry: resolve the handle either way.
630 let mut guard = FinalizeOnDrop {
631 shared: Some(Arc::clone(&shared)),
632 };
633 let callback: Callback = Box::new(move || {
634 guard.defuse();
635 // Honor an abort that landed before we started running.
636 if task_shared.control.abort.load(Ordering::Acquire) {
637 task_shared.finalize(Err(JoinError::cancelled()));
638 return;
639 }
640 let outcome = catch_unwind(AssertUnwindSafe(f));
641 let result = match outcome {
642 Ok(value) => Ok(value),
643 Err(_panic) => Err(JoinError::panicked()),
644 };
645 task_shared.finalize(result);
646 });
647
648 if callbacks.request(priority, callback).is_err() {
649 tracing::error!(
650 target: "epics_base_rs::runtime::future_exec",
651 "spawn_blocking_on: callback ring full; closure dropped, handle resolves cancelled"
652 );
653 }
654 JoinFuture { shared }
655}
656
657/// Resolves a handle as cancelled unless [`defuse`](Self::defuse)d — the
658/// blocking-closure counterpart of [`Entry`]'s `Drop`.
659struct FinalizeOnDrop<R> {
660 shared: Option<Arc<Shared<R>>>,
661}
662
663impl<R> FinalizeOnDrop<R> {
664 fn defuse(&mut self) {
665 self.shared = None;
666 }
667}
668
669impl<R> Drop for FinalizeOnDrop<R> {
670 fn drop(&mut self) {
671 if let Some(shared) = self.shared.take() {
672 shared.finalize(Err(JoinError::cancelled()));
673 }
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use crate::runtime::background::callback_executor::{
681 CallbackPool, DEFAULT_QUEUE_SIZE, DEFAULT_THREADS_PER_PRIORITY,
682 };
683 use crate::runtime::background::delayed_timer::DelayedTimer;
684 use crate::runtime::background::timer_sleep::sleep;
685 use crate::runtime::task::park_on_interruptible as drive;
686 use std::sync::mpsc;
687 use std::time::Duration;
688
689 const T: Duration = Duration::from_secs(5);
690
691 /// Block the test thread on a `JoinFuture`, returning its `Result`.
692 fn join<T>(jf: JoinFuture<T>) -> Result<T, JoinError> {
693 drive(jf, || false).expect("uncancelled join returned None")
694 }
695
696 // `JoinFuture` is single-await; several tests need both a method call and a
697 // join. Re-expose the shared handle by cloning the Arc so the test can do
698 // both without moving the handle into `join`.
699 fn jf_reborrow<T>(jf: &JoinFuture<T>) -> JoinFuture<T> {
700 JoinFuture {
701 shared: Arc::clone(&jf.shared),
702 }
703 }
704
705 /// Suspends, waking itself each poll, until the flag is set — the
706 /// unbounded [`YieldN`]. For tests whose subject is *that* a task is
707 /// suspended rather than how often: a fixed count makes the test thread
708 /// race the worker, because the worker may exhaust every yield before the
709 /// thread reaches its next statement.
710 struct YieldUntil(Arc<AtomicBool>);
711
712 impl Future for YieldUntil {
713 type Output = ();
714
715 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
716 if self.0.load(Ordering::Acquire) {
717 return Poll::Ready(());
718 }
719 cx.waker().wake_by_ref();
720 Poll::Pending
721 }
722 }
723
724 /// Returns `Pending` `n` times, waking itself each time, then `Ready`.
725 /// Exercises the wake-during-poll (`RUNNING_NOTIFIED`) edge on every step.
726 struct YieldN(usize);
727
728 impl Future for YieldN {
729 type Output = ();
730
731 fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
732 if self.0 == 0 {
733 return Poll::Ready(());
734 }
735 self.0 -= 1;
736 cx.waker().wake_by_ref();
737 Poll::Pending
738 }
739 }
740
741 // -- basic execution ----------------------------------------------------
742
743 #[test]
744 fn future_runs_to_completion() {
745 let pool = CallbackPool::new();
746 let jf = spawn_future(&pool.handle(), DEFAULT_SPAWN_PRIORITY, async { 42u32 });
747 assert_eq!(join(jf).unwrap(), 42);
748 }
749
750 #[test]
751 fn future_awaiting_cross_thread_primitive_completes() {
752 // The A2 precondition: a spawned tail that awaits a runtime-agnostic
753 // tokio::sync primitive, woken from ANOTHER thread, must complete with
754 // no tokio runtime present.
755 let pool = CallbackPool::new();
756 let (tx, rx) = tokio::sync::oneshot::channel::<u32>();
757 let jf = spawn_future(&pool.handle(), DEFAULT_SPAWN_PRIORITY, async move {
758 rx.await.unwrap()
759 });
760 // Send from the test thread after the task has already yielded.
761 std::thread::sleep(Duration::from_millis(20));
762 tx.send(7).unwrap();
763 assert_eq!(join(jf).unwrap(), 7);
764 }
765
766 #[test]
767 fn panic_in_task_does_not_kill_the_worker() {
768 // callback.c:210-235 drain-loop invariant: one bad callback must not
769 // stop the band. The panicked task reports a non-cancelled JoinError,
770 // and a subsequent task on the SAME pool still runs.
771 let pool = CallbackPool::new();
772
773 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async {
774 panic!("boom");
775 });
776 let err = join(jf).unwrap_err();
777 assert!(!err.is_cancelled(), "a panic is not a cancellation");
778
779 // Same pool, same band — the worker survived and drains the next task.
780 let jf2 = spawn_future(&pool.handle(), CallbackPriority::Medium, async { 99u32 });
781 assert_eq!(join(jf2).unwrap(), 99);
782 }
783
784 #[test]
785 fn panic_after_a_yield_is_isolated_too() {
786 // Boundary partner to the above: the panic happens on a LATER poll, so
787 // it unwinds out of a re-enqueued run rather than the first one.
788 let pool = CallbackPool::new();
789 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async {
790 YieldN(3).await;
791 panic!("late boom");
792 });
793 assert!(!join::<()>(jf).unwrap_err().is_cancelled());
794
795 let jf2 = spawn_future(&pool.handle(), CallbackPriority::Medium, async { 7u32 });
796 assert_eq!(join(jf2).unwrap(), 7);
797 }
798
799 // -- invariant 4: a synchronous completion never round-trips the queue ---
800
801 #[test]
802 fn synchronously_ready_future_is_enqueued_exactly_once() {
803 // Boundary: zero suspensions. The spawn itself is one enqueue; a task
804 // that is Ready on its first poll must add no more.
805 let pool = CallbackPool::new();
806 let (jf, task) = spawn_task(&pool.handle(), CallbackPriority::Medium, async { 1u8 });
807 assert_eq!(join(jf).unwrap(), 1);
808 assert_eq!(
809 task.enqueues.load(Ordering::Relaxed),
810 1,
811 "a future that completes on its first poll must not round-trip the ring"
812 );
813 }
814
815 #[test]
816 fn each_suspension_costs_exactly_one_re_enqueue() {
817 // The other side of the same boundary: N suspensions → N re-enqueues,
818 // i.e. the queue is used once per wake and never speculatively.
819 let pool = CallbackPool::new();
820 let (jf, task) = spawn_task(&pool.handle(), CallbackPriority::Medium, YieldN(3));
821 join(jf).unwrap();
822 assert_eq!(
823 task.enqueues.load(Ordering::Relaxed),
824 4,
825 "expected 1 spawn + 3 wakes"
826 );
827 }
828
829 // -- invariant 3: a released worker is reused; a woken task does not starve
830
831 #[test]
832 fn a_yielding_task_releases_the_worker_to_a_queued_task() {
833 // Single worker, two tasks. `slow` stays suspended until this thread
834 // releases it; `quick` is queued behind it. Under the old
835 // park-a-worker design `quick` could not run until `slow` finished, so
836 // it would never arrive at all.
837 //
838 // The gate is what makes the order an assertion rather than a race.
839 // Holding `slow` for a fixed number of yields instead leaves the
840 // executor free to burn all of them before this thread has enqueued
841 // `quick` — then "slow" arrives first through no fault of the
842 // executor, which is the flake this shape removes. Gated, `slow`
843 // *cannot* finish before "quick" is received, so "quick" arriving is
844 // itself the proof that a suspended task released the band's only
845 // worker.
846 assert_eq!(DEFAULT_THREADS_PER_PRIORITY, 1);
847 let pool = CallbackPool::new();
848 let (tx, rx) = mpsc::channel::<&'static str>();
849 let gate = Arc::new(AtomicBool::new(false));
850
851 let tx_slow = tx.clone();
852 let gate_slow = Arc::clone(&gate);
853 let slow = spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
854 YieldUntil(gate_slow).await;
855 tx_slow.send("slow").unwrap();
856 });
857 let quick = spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
858 tx.send("quick").unwrap();
859 });
860
861 assert_eq!(
862 rx.recv_timeout(T).unwrap(),
863 "quick",
864 "a suspended task must not hold the band's only worker"
865 );
866 gate.store(true, Ordering::Release);
867 assert_eq!(
868 rx.recv_timeout(T).unwrap(),
869 "slow",
870 "a repeatedly re-enqueued task must still make progress"
871 );
872 join(quick).unwrap();
873 join(slow).unwrap();
874 }
875
876 #[test]
877 fn many_self_waking_tasks_all_finish_on_one_worker() {
878 // No-starvation under contention: 8 tasks, each waking itself 25 times,
879 // multiplexed over a single worker. Tail re-enqueue makes this fair
880 // enough that every one of them terminates.
881 assert_eq!(DEFAULT_THREADS_PER_PRIORITY, 1);
882 let pool = CallbackPool::new();
883 let handles: Vec<_> = (0..8)
884 .map(|i| {
885 spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
886 YieldN(25).await;
887 i
888 })
889 })
890 .collect();
891 for (i, jf) in handles.into_iter().enumerate() {
892 assert_eq!(join(jf).unwrap(), i);
893 }
894 }
895
896 // -- invariant 1: the sleep-wake inline path ----------------------------
897
898 #[test]
899 fn spawned_future_awaiting_sleep_does_not_self_deadlock() {
900 // Regression for the sleep-wake self-deadlock (bug_pattern
901 // rtems-exec-sleep-wake-band-deadlock): a future SPAWNED ON THE POOL
902 // that awaits `timer_sleep::sleep` must complete. This is the exact
903 // `spawn(async { sleep().await })` shape that ODLY/SDLY async-record
904 // reprocessing uses on the RTEMS backend.
905 //
906 // Why the existing `timer_sleep` unit tests did NOT catch it: they
907 // `drive()` the Sleep on the TEST thread, leaving the pool's single
908 // Medium worker free to run the wake callback. Here the future runs on
909 // the pool worker (via `spawn_future`) — so with the old behavior, where
910 // the sleep-wake was `sink.request(Medium, ...)`, the wake queued behind
911 // the very worker parked on this future (one worker per band) and never
912 // ran. We must OBSERVE completion via a channel, NOT `join()`/`drive()`
913 // on the test thread, or we would re-introduce the free-worker escape
914 // hatch and stop reproducing the deadlock.
915 let pool = CallbackPool::new();
916 let timer = DelayedTimer::new(pool.handle());
917 let th = timer.handle();
918
919 let (done_tx, done_rx) = mpsc::channel::<()>();
920 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
921 sleep(&th, Duration::from_millis(50)).await;
922 done_tx.send(()).unwrap();
923 });
924
925 // With the fix (inline wake on the timer thread) the sleep fires and the
926 // future finishes well inside T. With the pre-fix band-dispatch wake this
927 // recv times out because the wake starves behind the parked worker.
928 done_rx.recv_timeout(T).expect(
929 "spawned future awaiting sleep deadlocked (wake starved behind its own worker)",
930 );
931 assert!(join(jf).is_ok());
932 }
933
934 #[test]
935 fn a_sleep_wake_needs_no_pool_worker() {
936 // The inline-wake guarantee, stated directly: pin the band's ONLY
937 // worker inside an unrelated blocking callback, and a sleeping task's
938 // wake must still land. If the wake needed a worker it would sit behind
939 // the pinned one and `fired` would stay false.
940 assert_eq!(DEFAULT_THREADS_PER_PRIORITY, 1);
941 let pool = CallbackPool::new();
942 let timer = DelayedTimer::new(pool.handle());
943
944 let (pinned_tx, pinned_rx) = mpsc::channel::<()>();
945 let (release_tx, release_rx) = mpsc::channel::<()>();
946 pool.request(
947 CallbackPriority::Medium,
948 Box::new(move || {
949 pinned_tx.send(()).unwrap();
950 release_rx.recv().unwrap();
951 }),
952 )
953 .unwrap();
954 pinned_rx.recv_timeout(T).unwrap(); // the sole Medium worker is busy
955
956 let (woke_tx, woke_rx) = mpsc::channel::<()>();
957 // Drive the Sleep on the TEST thread — no pool worker involved at all,
958 // so what this observes is purely "did the wake arrive".
959 let th = timer.handle();
960 let sleeper = std::thread::spawn(move || {
961 drive(sleep(&th, Duration::from_millis(30)), || false).unwrap();
962 woke_tx.send(()).unwrap();
963 });
964 woke_rx
965 .recv_timeout(T)
966 .expect("sleep wake did not arrive while the band's worker was pinned");
967
968 release_tx.send(()).unwrap();
969 sleeper.join().unwrap();
970 }
971
972 #[test]
973 fn three_sleeping_tails_share_one_worker() {
974 // The old failure mode, gone. DEFAULT_THREADS_PER_PRIORITY is 1, so
975 // under the park-a-worker-per-future design these three tails would
976 // have needed three workers: #1 would hold the only one for its whole
977 // sleep, and #2 and #3 could not even START until it finished.
978 //
979 // The assertion is structural, not a stopwatch: every task announces
980 // Started before it sleeps and Done after. All three Starteds must
981 // arrive before the first Done — which is only possible if each task
982 // handed the worker back at its suspension point.
983 assert_eq!(DEFAULT_THREADS_PER_PRIORITY, 1);
984 let pool = CallbackPool::with_config(DEFAULT_QUEUE_SIZE, DEFAULT_THREADS_PER_PRIORITY);
985 let timer = DelayedTimer::new(pool.handle());
986
987 #[derive(Debug, PartialEq, Eq)]
988 enum Ev {
989 Started,
990 Done(u8),
991 }
992
993 let (tx, rx) = mpsc::channel::<Ev>();
994 let handles: Vec<_> = (0..3u8)
995 .map(|i| {
996 let th = timer.handle();
997 let tx = tx.clone();
998 spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
999 tx.send(Ev::Started).unwrap();
1000 sleep(&th, Duration::from_millis(120)).await;
1001 tx.send(Ev::Done(i)).unwrap();
1002 i
1003 })
1004 })
1005 .collect();
1006 drop(tx);
1007
1008 for n in 0..3 {
1009 assert_eq!(
1010 rx.recv_timeout(T).unwrap(),
1011 Ev::Started,
1012 "task {n} had not started before the first task finished — the \
1013 worker was held across a suspension"
1014 );
1015 }
1016 let mut done: Vec<u8> = (0..3)
1017 .map(|_| match rx.recv_timeout(T).unwrap() {
1018 Ev::Done(i) => i,
1019 Ev::Started => panic!("only three tasks exist"),
1020 })
1021 .collect();
1022 done.sort_unstable();
1023 assert_eq!(done, vec![0, 1, 2], "all three tails must complete");
1024
1025 for (i, jf) in handles.into_iter().enumerate() {
1026 assert_eq!(join(jf).unwrap() as usize, i);
1027 }
1028 }
1029
1030 // -- invariant 2: abort ------------------------------------------------
1031
1032 #[test]
1033 fn abort_while_idle_cancels_cleanly() {
1034 // Boundary: the task is IDLE — parked between polls on a primitive that
1035 // will never fire, holding no worker. Nothing but the abort can wake
1036 // it, so this is what proves `request_abort` schedules.
1037 let pool = CallbackPool::new();
1038 let (_tx, rx) = tokio::sync::oneshot::channel::<()>();
1039 let (ran_tx, ran_rx) = mpsc::channel();
1040 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
1041 ran_tx.send(()).unwrap();
1042 let _ = rx.await;
1043 });
1044 // The task has run at least once and yielded.
1045 ran_rx.recv_timeout(T).unwrap();
1046 std::thread::sleep(Duration::from_millis(20));
1047
1048 jf.abort();
1049 let err = join(jf_reborrow(&jf)).unwrap_err();
1050 assert!(err.is_cancelled(), "aborted task must report cancelled");
1051 assert!(jf.is_finished());
1052 }
1053
1054 #[test]
1055 fn abort_before_the_first_poll_cancels_without_running() {
1056 // Boundary: the task is SCHEDULED but not yet RUNNING. Pin the band's
1057 // only worker so the task cannot start, abort it, then release. It must
1058 // resolve cancelled and its body must never have executed.
1059 assert_eq!(DEFAULT_THREADS_PER_PRIORITY, 1);
1060 let pool = CallbackPool::new();
1061 let (pinned_tx, pinned_rx) = mpsc::channel::<()>();
1062 let (release_tx, release_rx) = mpsc::channel::<()>();
1063 pool.request(
1064 CallbackPriority::Medium,
1065 Box::new(move || {
1066 pinned_tx.send(()).unwrap();
1067 release_rx.recv().unwrap();
1068 }),
1069 )
1070 .unwrap();
1071 pinned_rx.recv_timeout(T).unwrap();
1072
1073 let ran = Arc::new(AtomicBool::new(false));
1074 let flag = Arc::clone(&ran);
1075 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
1076 flag.store(true, Ordering::SeqCst);
1077 });
1078 jf.abort();
1079 release_tx.send(()).unwrap();
1080
1081 assert!(join(jf_reborrow(&jf)).unwrap_err().is_cancelled());
1082 assert!(
1083 !ran.load(Ordering::SeqCst),
1084 "an abort observed before the first poll must not run the future"
1085 );
1086 }
1087
1088 #[test]
1089 fn abort_during_a_poll_is_observed_at_the_next_one() {
1090 // Boundary: the abort lands while the task is RUNNING, i.e. inside a
1091 // synchronous stretch. tokio's contract (and the previous park-driver's)
1092 // is that it is observed at the NEXT suspension point, not mid-poll —
1093 // so the current poll finishes and the following one cancels.
1094 let pool = CallbackPool::new();
1095 let (in_poll_tx, in_poll_rx) = mpsc::channel::<()>();
1096 let (go_tx, go_rx) = mpsc::channel::<()>();
1097 let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1098 let counter = Arc::clone(&polls);
1099
1100 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
1101 counter.fetch_add(1, Ordering::SeqCst);
1102 // Synchronous stretch: tell the test we are inside the poll and
1103 // block here until it has aborted us.
1104 in_poll_tx.send(()).unwrap();
1105 go_rx.recv().unwrap();
1106 YieldN(1).await; // the suspension point the cancel is observed at
1107 counter.fetch_add(100, Ordering::SeqCst);
1108 });
1109
1110 in_poll_rx.recv_timeout(T).unwrap();
1111 jf.abort(); // lands while the task is RUNNING
1112 go_tx.send(()).unwrap();
1113
1114 assert!(join(jf_reborrow(&jf)).unwrap_err().is_cancelled());
1115 assert_eq!(
1116 polls.load(Ordering::SeqCst),
1117 1,
1118 "the code after the suspension point must not have run"
1119 );
1120 }
1121
1122 #[test]
1123 fn abort_handle_cancels_the_task() {
1124 let pool = CallbackPool::new();
1125 let (_tx, rx) = tokio::sync::oneshot::channel::<()>();
1126 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
1127 let _ = rx.await;
1128 });
1129 let ah = jf.abort_handle();
1130 std::thread::sleep(Duration::from_millis(20));
1131 assert!(!ah.is_finished());
1132 ah.abort();
1133 assert!(join(jf).unwrap_err().is_cancelled());
1134 assert!(ah.is_finished());
1135 }
1136
1137 #[test]
1138 fn abort_after_completion_does_not_rewrite_the_outcome() {
1139 // Boundary: DONE. `finalize` is idempotent, so a late abort must not
1140 // turn a delivered value into a cancellation.
1141 let pool = CallbackPool::new();
1142 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async { 5u32 });
1143 while !jf.is_finished() {
1144 std::thread::sleep(Duration::from_millis(2));
1145 }
1146 jf.abort();
1147 assert_eq!(join(jf).unwrap(), 5);
1148 }
1149
1150 // -- every handle resolves ---------------------------------------------
1151
1152 /// Pin the band's single worker and fill its single ring slot, so the
1153 /// next `callbackRequest` is refused. Returns the release for the worker.
1154 fn saturate(pool: &CallbackPool) -> mpsc::Sender<()> {
1155 let (pinned_tx, pinned_rx) = mpsc::channel::<()>();
1156 let (release_tx, release_rx) = mpsc::channel::<()>();
1157 pool.request(
1158 CallbackPriority::Medium,
1159 Box::new(move || {
1160 pinned_tx.send(()).unwrap();
1161 release_rx.recv().unwrap();
1162 }),
1163 )
1164 .unwrap();
1165 pinned_rx.recv_timeout(T).unwrap();
1166 pool.request(CallbackPriority::Medium, Box::new(|| {}))
1167 .unwrap();
1168 assert!(
1169 pool.request(CallbackPriority::Medium, Box::new(|| {}))
1170 .is_err()
1171 );
1172 release_tx
1173 }
1174
1175 #[test]
1176 fn a_spawn_onto_a_full_ring_still_runs() {
1177 // Boundary: ring at capacity when the task is first queued.
1178 let pool = CallbackPool::with_config(1, 1);
1179 let release = saturate(&pool);
1180 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async { 1u32 });
1181 release.send(()).unwrap();
1182 assert_eq!(join(jf).unwrap(), 1);
1183 }
1184
1185 #[test]
1186 fn a_wake_onto_a_full_ring_is_not_lost() {
1187 // Boundary: ring at capacity when a suspended task is woken. A
1188 // long-lived consumer loop dies silently if this wake is refused.
1189 let pool = CallbackPool::with_config(1, 1);
1190 let (tx, rx) = tokio::sync::oneshot::channel::<u32>();
1191 let (polled_tx, polled_rx) = mpsc::channel::<()>();
1192 let jf = spawn_future(&pool.handle(), CallbackPriority::Medium, async move {
1193 polled_tx.send(()).unwrap();
1194 rx.await.unwrap()
1195 });
1196 polled_rx.recv_timeout(T).unwrap();
1197
1198 let release = saturate(&pool);
1199 tx.send(7).unwrap();
1200 release.send(()).unwrap();
1201 assert_eq!(join(jf).unwrap(), 7);
1202 }
1203
1204 #[test]
1205 fn spawn_blocking_returns_value_and_isolates_panic() {
1206 let pool = CallbackPool::new();
1207 let jf = spawn_blocking_on(&pool.handle(), CallbackPriority::Medium, || 123u32);
1208 assert_eq!(join(jf).unwrap(), 123);
1209
1210 let jf = spawn_blocking_on(&pool.handle(), CallbackPriority::Medium, || {
1211 panic!("blocking boom")
1212 });
1213 assert!(!join::<()>(jf).unwrap_err().is_cancelled());
1214
1215 // Worker survived the panic.
1216 let jf = spawn_blocking_on(&pool.handle(), CallbackPriority::Medium, || 5u32);
1217 assert_eq!(join(jf).unwrap(), 5);
1218 }
1219}