Skip to main content

epics_libcom_rs/runtime/
task.rs

1// RTEMS-EXEC-MODEL-ALLOW(2): the two multi-thread-flavored tests prove a
2// dedicated thread carries the ambient tokio runtime / requested stack; the
3// tokio flavor is the property under test. Both run and pass in the
4// feature-ON suite.
5
6use std::future::Future;
7use std::sync::Arc;
8use std::task::{Context, Poll, Wake, Waker};
9use std::time::Duration;
10use tokio::runtime::RuntimeFlavor;
11
12pub use tokio::runtime::Handle as RuntimeHandle;
13
14/// A synchronous caller asked to block on an async operation from a thread
15/// where blocking cannot be made sound.
16///
17/// Both variants are the same defect seen through two executors: the calling
18/// thread is one the awaited future needs in order to make progress, so parking
19/// it parks the thing that would wake it. No blocking mechanism can fix that;
20/// the caller has to `await` the async operation instead of blocking on it.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum NotBlockable {
23    /// A **current-thread** tokio runtime is entered on this thread. Parking it
24    /// stops every task on that runtime, including whichever one holds the
25    /// state the awaited future is waiting for.
26    CurrentThreadRuntime,
27    /// This thread is a background-facility worker — a callback band, the
28    /// delayed-callback timer, or the scanOnce worker
29    /// ([`crate::runtime::background`]). Each facility has a bounded worker set
30    /// and every unit of work it carries is enqueued for those workers, so a
31    /// parked worker is waiting for work only it could have run. On RTEMS
32    /// [`spawn`] routes here, which makes the callback bands the one other
33    /// place where parking is unsound.
34    BackgroundWorker,
35}
36
37impl std::fmt::Display for NotBlockable {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            NotBlockable::CurrentThreadRuntime => {
41                f.write_str("cannot block a current-thread runtime")
42            }
43            NotBlockable::BackgroundWorker => {
44                f.write_str("cannot block a background-facility worker thread")
45            }
46        }
47    }
48}
49
50impl std::error::Error for NotBlockable {}
51
52/// A [`Waker`] that unparks the thread that built it. The single owner of the
53/// "poll-then-park" wake mechanism in this crate: both [`park_on`] (the sync
54/// bridge) and the RTEMS future executor
55/// ([`crate::runtime::background::future_exec`]) drive a future by polling on a
56/// thread and parking it between polls, so both build one of these on their own
57/// thread and rely on the future's cross-thread waker to unpark them.
58pub(crate) struct ThreadWaker(std::thread::Thread);
59
60impl ThreadWaker {
61    /// A waker over the *current* thread — call this on the thread that will
62    /// park.
63    pub(crate) fn for_current_thread() -> Waker {
64        Waker::from(Arc::new(ThreadWaker(std::thread::current())))
65    }
66}
67
68impl Wake for ThreadWaker {
69    fn wake(self: Arc<Self>) {
70        self.0.unpark();
71    }
72    fn wake_by_ref(self: &Arc<Self>) {
73        self.0.unpark();
74    }
75}
76
77/// Drive `fut` to completion on this thread, parking between polls, and stop
78/// early when `should_cancel` returns `true`.
79///
80/// Returns `Some(output)` when the future completed, or `None` when it was
81/// cancelled before completing (the future is dropped in place on cancel,
82/// running its destructors — the same "drop at the next suspension point"
83/// semantics a cancelled tokio task has).
84///
85/// The future must only await runtime-agnostic primitives (`tokio::sync`
86/// locks/channels/notifies): nothing here drives a reactor or a timer wheel, so
87/// whoever wakes us must be running on some other thread. A cancel is observed
88/// on the next wake — the caller that flips `should_cancel` must also
89/// [`unpark`](std::thread::Thread::unpark) this thread so a *parked* driver
90/// re-checks promptly rather than sleeping until the future's own waker fires.
91pub(crate) fn park_on_interruptible<F: Future>(
92    fut: F,
93    mut should_cancel: impl FnMut() -> bool,
94) -> Option<F::Output> {
95    let mut fut = std::pin::pin!(fut);
96    let waker = ThreadWaker::for_current_thread();
97    let mut cx = Context::from_waker(&waker);
98    loop {
99        if should_cancel() {
100            return None;
101        }
102        if let Poll::Ready(value) = fut.as_mut().poll(&mut cx) {
103            return Some(value);
104        }
105        std::thread::park();
106    }
107}
108
109/// Drive `fut` to completion on this thread, parking between polls. Thin
110/// uncancellable wrapper over [`park_on_interruptible`].
111///
112/// The future must only await runtime-agnostic primitives (`tokio::sync`
113/// locks/channels/notifies): nothing here drives a reactor or a timer wheel, so
114/// whoever wakes us must be running on some other thread.
115fn park_on<F: Future>(fut: F) -> F::Output {
116    // Never cancels, so `park_on_interruptible` always returns `Some`.
117    park_on_interruptible(fut, || false).expect("uncancellable driver returned None")
118}
119
120/// Block the calling thread on `fut`, picking the mechanism that is sound for
121/// the thread we are actually on.
122///
123/// This is the single owner of "sync call over async state" in this crate; the
124/// four caller contexts are not interchangeable and picking one mechanism for
125/// all of them is what makes such bridges panic:
126///
127/// - **A background-facility worker** —
128///   [`Err(BackgroundWorker)`](NotBlockable::BackgroundWorker), checked first,
129///   because it is a property of the *thread* and holds whatever runtime is or
130///   is not entered on it. See
131///   [`background::facility::on_facility_thread`](crate::runtime::background)
132///   for why parking one is unsound.
133/// - **No runtime entered** (a plain `std::thread`, an iocsh thread) — park the
134///   thread. Nothing else runs here, so there is nothing to starve; the tasks
135///   that will wake us live on some other runtime's threads.
136/// - **Multi-thread runtime worker** — [`tokio::task::block_in_place`], which
137///   hands this worker's remaining tasks to a sibling before it is parked.
138/// - **Current-thread runtime** —
139///   [`Err(CurrentThreadRuntime)`](NotBlockable::CurrentThreadRuntime). Parking
140///   the only thread of that runtime halts every task on it, including the one
141///   that would wake us.
142///
143/// The two refusals are reported to the caller rather than panicked on (today)
144/// or deadlocked on (the worse alternative) — an illegal blocking bridge is a
145/// value the caller must handle, not a review item.
146pub fn block_on_sync<F: Future>(fut: F) -> Result<F::Output, NotBlockable> {
147    if crate::runtime::background::facility::on_facility_thread() {
148        return Err(NotBlockable::BackgroundWorker);
149    }
150    match RuntimeHandle::try_current() {
151        Ok(handle) => match handle.runtime_flavor() {
152            RuntimeFlavor::CurrentThread => Err(NotBlockable::CurrentThreadRuntime),
153            _ => Ok(tokio::task::block_in_place(|| handle.block_on(fut))),
154        },
155        Err(_) => Ok(park_on(fut)),
156    }
157}
158
159/// A capability, captured where the backend's executor is reachable, to run
160/// async work from a plain blocking thread (iocsh, a REPL, a script thread).
161///
162/// [`block_on_sync`] answers "may I block *here*, now?" per call and can only
163/// use whatever runtime is visible on the calling thread. This type answers
164/// the reachability question once, at [`capture`](Self::capture) time, and
165/// carries the answer to a thread the runtime is otherwise invisible from: a
166/// tokio handle is thread-local state, so a blocking thread spawned *before*
167/// it exists has no way to find it. The exec backend's executor is
168/// process-global, so there is nothing to carry and the bridge is a ZST —
169/// which is what makes an API taking a `BlockingBridge` compile and work on
170/// both backends, where one taking `tokio::runtime::Handle` pinned every
171/// caller to tokio.
172#[cfg(tokio_backend)]
173#[derive(Clone)]
174pub struct BlockingBridge {
175    handle: tokio::runtime::Handle,
176}
177
178/// See the `tokio_backend` definition. The executor here is the
179/// process-global background executor, reachable from any thread, so there is
180/// no state to capture.
181#[cfg(exec_backend)]
182#[derive(Clone)]
183pub struct BlockingBridge;
184
185#[cfg(tokio_backend)]
186impl BlockingBridge {
187    /// Capture the current tokio runtime.
188    ///
189    /// # Panics
190    /// Panics when no runtime is entered on this thread — call it on the
191    /// async setup path (where the runtime is known), not on the blocking
192    /// thread the bridge is being made for.
193    pub fn capture() -> Self {
194        Self {
195            handle: tokio::runtime::Handle::current(),
196        }
197    }
198
199    /// Drive `fut` to completion on this thread, with the captured runtime
200    /// entered so the future may spawn and use the reactor.
201    ///
202    /// # Panics
203    /// Panics on a runtime worker thread: blocking one parks tasks that may
204    /// include the future's own wakers (the same refusal `block_on_sync`
205    /// reports as a value).
206    pub fn block_on<F: Future>(&self, fut: F) -> F::Output {
207        assert!(
208            RuntimeHandle::try_current().is_err(),
209            "BlockingBridge::block_on must not be called from a runtime thread"
210        );
211        self.handle.block_on(fut)
212    }
213
214    /// Spawn `future` onto the captured runtime — [`spawn`] for a thread the
215    /// runtime is not entered on.
216    pub fn spawn<F>(&self, future: F) -> TaskHandle<F::Output>
217    where
218        F: Future + Send + 'static,
219        F::Output: Send + 'static,
220    {
221        self.handle.spawn(future)
222    }
223}
224
225#[cfg(exec_backend)]
226impl BlockingBridge {
227    /// The exec backend's executor is process-global; capturing is a no-op
228    /// and never panics.
229    pub fn capture() -> Self {
230        Self
231    }
232
233    /// Drive `fut` on this thread via [`park_on`]; whatever it spawns or
234    /// sleeps on lands on the background executor.
235    pub fn block_on<F: Future>(&self, fut: F) -> F::Output {
236        park_on(fut)
237    }
238
239    /// [`spawn`] — the global executor needs no captured state.
240    pub fn spawn<F>(&self, future: F) -> TaskHandle<F::Output>
241    where
242        F: Future + Send + 'static,
243        F::Output: Send + 'static,
244    {
245        spawn(future)
246    }
247}
248
249/// Drive an async test body to completion — the driver behind
250/// `#[epics_test]` (`epics-macros-rs`).
251///
252/// The point of the indirection is that the *backend* picks the driver, not
253/// the test. On `tokio_backend` this builds exactly what `#[tokio::test]`
254/// builds: a fresh current-thread runtime with IO and time enabled. On
255/// `exec_backend` (the RTEMS target, or a host run with
256/// `--features rtems-exec-model`) no tokio runtime exists to build, so the
257/// test thread itself drives the future via [`park_on`], and everything the
258/// body spawns or sleeps on lands on the process-global background executor
259/// (lazily initialised on first use) — the same seam the RTEMS boot path
260/// exercises. A test written with `#[epics_test]` therefore needs no
261/// per-backend gating and no `RTEMS-EXEC-MODEL-ALLOW` census entry.
262#[cfg(tokio_backend)]
263pub fn test_block_on<F: Future>(fut: F) -> F::Output {
264    tokio::runtime::Builder::new_current_thread()
265        .enable_all()
266        .build()
267        .expect("failed to build tokio test runtime")
268        .block_on(fut)
269}
270
271/// `exec_backend` twin of [`test_block_on`]: see the `tokio_backend` copy for
272/// the contract. The body's awaits must reach only runtime-agnostic
273/// primitives (`park_on`'s rule) — a body that touches `tokio::net` or
274/// `tokio::time` directly belongs under `#[tokio::test]` with a backend gate
275/// instead.
276#[cfg(exec_backend)]
277pub fn test_block_on<F: Future>(fut: F) -> F::Output {
278    park_on(fut)
279}
280
281// ---------------------------------------------------------------------------
282// Platform-selected task handle types (decision A2 / B)
283//
284// The seam hands back one of these aliases from every spawn; call sites in this
285// crate name only the alias, never a tokio handle. Hosted = the tokio handle
286// types. RTEMS = the always-compiled, host-tested mirrors in
287// `background::future_exec` (`JoinFuture`/`AbortHandle`/`JoinError`), which
288// reproduce exactly the subset of the tokio surface the call sites use.
289// ---------------------------------------------------------------------------
290
291/// `true` when [`spawn`] lands the future on the tokio runtime, `false` when it
292/// lands on the reactor-free background executor (`exec_backend` — the RTEMS
293/// target, or a host build with `--features rtems-exec-model`).
294///
295/// # What this is for
296///
297/// It is the *exported* form of `build.rs`'s backend decision, and the reason
298/// it is exported is that a spawned future's access to a tokio **reactor** is
299/// decided here and consumed in other crates. A future handed to [`spawn`] on
300/// `exec_backend` runs on a callback-pool worker with no reactor entered, so
301/// every `tokio::net` socket it opens panics — *even in a process that has a
302/// tokio runtime somewhere else*, because the runtime is not entered on that
303/// worker.
304///
305/// `epics-ca-rs` and `epics-pva-rs` therefore have to make the same decision
306/// this crate makes, for their own compilation, and they make it in their own
307/// `build.rs` from the same two inputs (target OS, `rtems-exec-model` feature).
308/// That is three copies of one rule, so each of them pins the copy against this
309/// constant with a `const` assertion — a build where the two disagree (say,
310/// `epics-base-rs/rtems-exec-model` enabled without `epics-ca-rs`'s) fails to
311/// compile instead of panicking at boot.
312pub const HAS_TOKIO_REACTOR: bool = cfg!(tokio_backend);
313
314/// Handle to a spawned task — `await` for its result, `abort()` to cancel.
315#[cfg(tokio_backend)]
316pub type TaskHandle<T> = tokio::task::JoinHandle<T>;
317/// Detached cancellation handle for a spawned task.
318#[cfg(tokio_backend)]
319pub type TaskAbortHandle = tokio::task::AbortHandle;
320/// Error from awaiting a [`TaskHandle`] (cancelled or panicked).
321#[cfg(tokio_backend)]
322pub type TaskJoinError = tokio::task::JoinError;
323
324#[cfg(exec_backend)]
325pub type TaskHandle<T> = crate::runtime::background::future_exec::JoinFuture<T>;
326#[cfg(exec_backend)]
327pub type TaskAbortHandle = crate::runtime::background::future_exec::AbortHandle;
328#[cfg(exec_backend)]
329pub type TaskJoinError = crate::runtime::background::future_exec::JoinError;
330
331// ---------------------------------------------------------------------------
332// Process-global background executor (RTEMS spawn/timer backend)
333//
334// On RTEMS the seam routes every spawn/sleep/interval into one process-global
335// `BackgroundExecutor` (callback pool + delayed timer + scanOnce worker). Two
336// init paths, both landing on the same `OnceLock`:
337//
338//   * Explicit — `background_init()` from `IocApplication::run`, mirroring C's
339//     `callbackInit` running early in `iocInit` (callback.c:286) so the
340//     facilities exist before any record processing can defer a tail.
341//   * Lazy fallback — the first `spawn`/`sleep`/`interval` on a path that
342//     never went through `run` (a unit test, an embedded harness) initialises
343//     it on demand via the same `get_or_init`.
344//
345// Compiled on RTEMS and under `cfg(test)` (so the wiring is host-exercised);
346// on a hosted non-test build the tokio runtime is the backend and this is not
347// compiled.
348// ---------------------------------------------------------------------------
349
350#[cfg(any(exec_backend, test))]
351static BACKGROUND: std::sync::OnceLock<crate::runtime::background::BackgroundExecutor> =
352    std::sync::OnceLock::new();
353
354/// The process-global background executor, initialised on first use.
355#[cfg(any(exec_backend, test))]
356fn background() -> &'static crate::runtime::background::BackgroundExecutor {
357    BACKGROUND.get_or_init(crate::runtime::background::BackgroundExecutor::new)
358}
359
360/// Eagerly start the process-global background executor — C `callbackInit`
361/// parity (callback.c:286), called once from `IocApplication::run`. Idempotent:
362/// a second call (or a prior lazy init) is a no-op, matching `callbackInit`'s
363/// own re-entry guard (callback.c:292-295). Only the RTEMS build uses the
364/// executor; hosted builds drive tails on the tokio runtime.
365#[cfg(any(exec_backend, test))]
366pub fn background_init() {
367    let _ = background();
368}
369
370#[cfg(tokio_backend)]
371pub fn spawn<F>(future: F) -> TaskHandle<F::Output>
372where
373    F: Future + Send + 'static,
374    F::Output: Send + 'static,
375{
376    tokio::spawn(future)
377}
378
379/// RTEMS: drive the tail on a callback-pool worker via the host-tested future
380/// executor, at the default Medium band.
381#[cfg(exec_backend)]
382pub fn spawn<F>(future: F) -> TaskHandle<F::Output>
383where
384    F: Future + Send + 'static,
385    F::Output: Send + 'static,
386{
387    use crate::runtime::background::future_exec::{DEFAULT_SPAWN_PRIORITY, spawn_future};
388    spawn_future(
389        &background().callbacks().handle(),
390        DEFAULT_SPAWN_PRIORITY,
391        future,
392    )
393}
394
395/// Yield the current task once — the seam replacement for
396/// `tokio::task::yield_now`, so no call site names `tokio::task` directly.
397#[cfg(tokio_backend)]
398pub async fn yield_now() {
399    tokio::task::yield_now().await;
400}
401
402/// Exec-backend yield: return `Pending` once with the waker already woken.
403/// On the cooperative background executor that re-enqueues the task behind
404/// whatever else is runnable; under a `park_on` driver the wake sets the
405/// park token, so the driver re-polls immediately — both give one fair
406/// scheduling point, which is all `yield_now` promises.
407#[cfg(exec_backend)]
408pub async fn yield_now() {
409    struct YieldNow(bool);
410    impl Future for YieldNow {
411        type Output = ();
412        fn poll(mut self: std::pin::Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
413            if self.0 {
414                Poll::Ready(())
415            } else {
416                self.0 = true;
417                cx.waker().wake_by_ref();
418                Poll::Pending
419            }
420        }
421    }
422    YieldNow(false).await;
423}
424
425#[cfg(tokio_backend)]
426pub fn spawn_blocking<F, R>(f: F) -> TaskHandle<R>
427where
428    F: FnOnce() -> R + Send + 'static,
429    R: Send + 'static,
430{
431    tokio::task::spawn_blocking(f)
432}
433
434/// RTEMS: run the blocking closure on a callback-pool worker at the default
435/// Medium band via the host-tested future executor.
436#[cfg(exec_backend)]
437pub fn spawn_blocking<F, R>(f: F) -> TaskHandle<R>
438where
439    F: FnOnce() -> R + Send + 'static,
440    R: Send + 'static,
441{
442    use crate::runtime::background::future_exec::{DEFAULT_SPAWN_PRIORITY, spawn_blocking_on};
443    spawn_blocking_on(
444        &background().callbacks().handle(),
445        DEFAULT_SPAWN_PRIORITY,
446        f,
447    )
448}
449
450/// A set of spawned tasks, joined as they complete — the seam replacement for
451/// `tokio::task::JoinSet`.
452///
453/// `JoinSet` is a **fourth spelling of `tokio::spawn`**, and the one no seam
454/// guard caught: `JoinSet::spawn` calls `tokio::spawn` internally, so it panics
455/// with *"there is no reactor running"* on any thread that is not inside a
456/// tokio runtime — which on RTEMS is every callback-band worker. Measured on
457/// target: the CA client's transport manager died on `cbMedium` at its first
458/// connect (`doc/calink-rtems-design.md` §11.1). Naming it here means a call
459/// site can express "spawn a set of tasks and reap them as they finish"
460/// without reaching past the seam.
461///
462/// The three properties call sites depend on, all preserved:
463///
464/// * **Concurrency** — every member runs independently; joining one does not
465///   block the others.
466/// * **Pair-by-value** — [`Self::join_next`] yields whichever member finished
467///   first, so a task that returns its own key can be matched to its state.
468/// * **Abort on drop** — dropping the set cancels every member that has not
469///   finished, which is the property that distinguishes a `JoinSet` from a bag
470///   of detached `JoinHandle`s.
471pub struct TaskSet<T> {
472    tasks: Vec<TaskHandle<T>>,
473}
474
475impl<T> Default for TaskSet<T> {
476    fn default() -> Self {
477        Self::new()
478    }
479}
480
481impl<T> TaskSet<T> {
482    /// An empty set.
483    pub fn new() -> Self {
484        Self { tasks: Vec::new() }
485    }
486
487    /// Number of members that have not yet been joined.
488    pub fn len(&self) -> usize {
489        self.tasks.len()
490    }
491
492    /// `true` when no member is outstanding.
493    pub fn is_empty(&self) -> bool {
494        self.tasks.is_empty()
495    }
496}
497
498impl<T: Send + 'static> TaskSet<T> {
499    /// Spawn `future` into the set — through [`spawn`], so the RTEMS build
500    /// lands it on a callback band instead of demanding a tokio runtime.
501    pub fn spawn<F>(&mut self, future: F)
502    where
503        F: Future<Output = T> + Send + 'static,
504    {
505        self.tasks.push(spawn(future));
506    }
507
508    /// Wait for the next member to finish and return its result, removing it
509    /// from the set. `None` when the set is empty — matching
510    /// `JoinSet::join_next`, so a `select!` arm on it goes quiet rather than
511    /// spinning once every task has been reaped.
512    ///
513    /// Cancel-safe: the returned future holds no state of its own, so a
514    /// `select!` that drops it loses nothing.
515    pub async fn join_next(&mut self) -> Option<Result<T, TaskJoinError>> {
516        if self.tasks.is_empty() {
517            return None;
518        }
519        std::future::poll_fn(|cx| {
520            for i in 0..self.tasks.len() {
521                // Both backends' handles are `Unpin` (tokio's `JoinHandle`,
522                // and `JoinFuture`, whose only field is an `Arc`), so this
523                // needs no pin projection. Polling every pending member
524                // re-registers this waker with each — the shape both handles
525                // document.
526                if let std::task::Poll::Ready(result) =
527                    std::pin::Pin::new(&mut self.tasks[i]).poll(cx)
528                {
529                    self.tasks.swap_remove(i);
530                    return std::task::Poll::Ready(Some(result));
531                }
532            }
533            std::task::Poll::Pending
534        })
535        .await
536    }
537}
538
539impl<T> Drop for TaskSet<T> {
540    /// Cancel every outstanding member — `JoinSet`'s drop behaviour, and the
541    /// reason a call site reaches for a set rather than a `Vec` of handles.
542    fn drop(&mut self) {
543        for task in &self.tasks {
544            task.abort();
545        }
546    }
547}
548
549#[cfg(tokio_backend)]
550pub async fn sleep(duration: Duration) {
551    tokio::time::sleep(duration).await;
552}
553
554/// RTEMS: sleep on the delayed-callback timer via the host-tested `Sleep`.
555#[cfg(exec_backend)]
556pub async fn sleep(duration: Duration) {
557    crate::runtime::background::timer_sleep::sleep(&background().timer().handle(), duration).await;
558}
559
560/// The instant [`sleep_until`] measures deadlines against — **the backend's own
561/// clock**, which is the whole point of naming it here.
562///
563/// A deadline is only meaningful in the clock the timer that waits on it runs
564/// on. The hosted timer is tokio's, and under `#[tokio::test(start_paused =
565/// true)]` tokio's clock is virtual and advances on `sleep`, not with the wall
566/// — so a `std::time::Instant` deadline handed to a tokio timer is a deadline
567/// in a *different* timeline, and the wait is wrong by however far the two have
568/// diverged. The RTEMS timer runs on `std::time::Instant` (1-second-quantized
569/// on target, `doc/calink-rtems-design.md` §5.5).
570///
571/// Taking the alias rather than a concrete instant type is what keeps a caller
572/// from mixing them: `Instant::now() + timeout` is the deadline `sleep_until`
573/// will actually honour, on both backends.
574#[cfg(tokio_backend)]
575pub type Instant = tokio::time::Instant;
576/// See the hosted definition.
577#[cfg(exec_backend)]
578pub type Instant = std::time::Instant;
579
580#[cfg(tokio_backend)]
581pub async fn sleep_until(deadline: Instant) {
582    tokio::time::sleep_until(deadline).await;
583}
584
585/// RTEMS: sleep-until on the delayed-callback timer via the host-tested `Sleep`.
586#[cfg(exec_backend)]
587pub async fn sleep_until(deadline: Instant) {
588    crate::runtime::background::timer_sleep::sleep_until(&background().timer().handle(), deadline)
589        .await;
590}
591
592/// Periodic ticker — the seam replacement for `tokio::time::interval`, so no
593/// production site names `tokio::time` directly (decision A2). The hosted build
594/// wraps `tokio::time::Interval`, preserving its default
595/// `MissedTickBehavior::Burst` catch-up and immediate first tick; the RTEMS
596/// build substitutes the runtime-free
597/// [`crate::runtime::background::timer_sleep::TimerInterval`], which reproduces
598/// the same semantics over the delayed-callback timer.
599#[cfg(tokio_backend)]
600pub struct Interval {
601    inner: tokio::time::Interval,
602}
603
604#[cfg(tokio_backend)]
605impl Interval {
606    /// Complete at the next tick. The first tick is immediate (tokio parity);
607    /// callers that want to skip it await `tick()` once up front.
608    pub async fn tick(&mut self) {
609        self.inner.tick().await;
610    }
611}
612
613/// RTEMS: the periodic ticker is the runtime-free `TimerInterval` (same
614/// immediate-first-tick + Burst catch-up semantics, same `tick()` surface).
615#[cfg(exec_backend)]
616pub type Interval = crate::runtime::background::timer_sleep::TimerInterval;
617
618/// Build a periodic ticker firing every `period` — the seam replacement for
619/// `tokio::time::interval`.
620#[cfg(tokio_backend)]
621pub fn interval(period: Duration) -> Interval {
622    Interval {
623        inner: tokio::time::interval(period),
624    }
625}
626
627/// RTEMS: build the periodic ticker on the delayed-callback timer.
628#[cfg(exec_backend)]
629pub fn interval(period: Duration) -> Interval {
630    crate::runtime::background::timer_sleep::interval(&background().timer().handle(), period)
631}
632
633/// The timeout's error — "the deadline elapsed before the future completed".
634/// Hosted this is tokio's own type so `timeout` composes with tokio-aware
635/// callers; the exec backend substitutes a mirror (same Decision-B alias
636/// pattern as `TaskJoinError` — the consumed surface is Debug/Display only).
637#[cfg(tokio_backend)]
638pub use tokio::time::error::Elapsed;
639
640/// Exec-backend mirror of [`tokio::time::error::Elapsed`] (that type has no
641/// public constructor, so the runtime-free `timeout` below cannot return it).
642#[cfg(exec_backend)]
643#[derive(Debug)]
644pub struct Elapsed(());
645
646#[cfg(exec_backend)]
647impl std::fmt::Display for Elapsed {
648    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
649        write!(f, "deadline has elapsed")
650    }
651}
652
653#[cfg(exec_backend)]
654impl std::error::Error for Elapsed {}
655
656/// Await `fut`, giving up after `duration` — the seam's only bounded wait.
657///
658/// It belongs here rather than at the call sites for the same reason [`sleep`]
659/// does: a deadline needs a timer, and which timer that is depends on the
660/// backend. Call sites that reach for `tokio::time::timeout` directly pin
661/// themselves to the tokio timer wheel.
662#[cfg(tokio_backend)]
663pub async fn timeout<F: Future>(duration: Duration, fut: F) -> Result<F::Output, Elapsed> {
664    tokio::time::timeout(duration, fut).await
665}
666
667/// RTEMS/exec: the same bounded wait raced against the delayed-callback
668/// timer's [`sleep`] — no tokio timer wheel, no runtime.
669#[cfg(exec_backend)]
670pub async fn timeout<F: Future>(duration: Duration, fut: F) -> Result<F::Output, Elapsed> {
671    let mut sleep = std::pin::pin!(sleep(duration));
672    let mut fut = std::pin::pin!(fut);
673    std::future::poll_fn(move |cx| {
674        if let Poll::Ready(v) = fut.as_mut().poll(cx) {
675            return Poll::Ready(Ok(v));
676        }
677        if sleep.as_mut().poll(cx).is_ready() {
678            return Poll::Ready(Err(Elapsed(())));
679        }
680        Poll::Pending
681    })
682    .await
683}
684
685/// [`timeout`] against an absolute [`Instant`] instead of a duration — the
686/// seam's `tokio::time::timeout_at`.
687///
688/// It exists because one deadline shared across several sequential awaits is a
689/// different bound from a fresh duration per await: `caget_many` gives its
690/// whole batch one deadline, so a slow first PV eats the budget the rest would
691/// otherwise each get in full. Expressing that with `timeout` would need the
692/// caller to do the subtraction, which is the arithmetic that drifts.
693#[cfg(tokio_backend)]
694pub async fn timeout_at<F: Future>(deadline: Instant, fut: F) -> Result<F::Output, Elapsed> {
695    tokio::time::timeout_at(deadline, fut).await
696}
697
698/// RTEMS/exec: raced against [`sleep_until`], the absolute-deadline twin of
699/// what [`timeout`] races against.
700#[cfg(exec_backend)]
701pub async fn timeout_at<F: Future>(deadline: Instant, fut: F) -> Result<F::Output, Elapsed> {
702    let mut sleep = std::pin::pin!(sleep_until(deadline));
703    let mut fut = std::pin::pin!(fut);
704    std::future::poll_fn(move |cx| {
705        if let Poll::Ready(v) = fut.as_mut().poll(cx) {
706            return Poll::Ready(Ok(v));
707        }
708        if sleep.as_mut().poll(cx).is_ready() {
709            return Poll::Ready(Err(Elapsed(())));
710        }
711        Poll::Pending
712    })
713    .await
714}
715
716pub fn runtime_handle() -> tokio::runtime::Handle {
717    tokio::runtime::Handle::current()
718}
719
720// ---------------------------------------------------------------------------
721// EPICS thread priority abstraction
722//
723// C parity: `modules/libcom/src/osi/epicsThread.h:73-92` defines an
724// integer priority space `0..=99` (`epicsThreadPriorityMin/Max`) with a
725// set of named levels, plus three stack-size classes.
726// `osi/os/posix/osdThread.c` maps an EPICS priority `p` onto the OS
727// SCHED_FIFO range with `oss = p * (max-min)/100 + min` and falls back
728// to a non-RT (default-policy) thread when the process lacks permission
729// to use SCHED_FIFO.
730//
731// The Rust port runs work as tokio tasks on a shared pool, so there is
732// no per-task OS thread to re-prioritise for `spawn`. What is portably
733// achievable is: (a) the priority enum + named levels as a first-class
734// type, (b) a stack-size class with the C size table, and (c) a
735// best-effort OS-scheduler priority applied to the *current* OS thread
736// (used by dedicated `spawn_blocking` threads and the runtime's worker
737// threads). `apply_to_current_thread` reports whether the OS actually
738// honoured the request.
739//
740// (c) is opt-in and off by default — see `RT_PRIORITY_ENV`. C's switch
741// (`EPICS_ALLOW_POSIX_THREAD_PRIORITY_SCHEDULING`) defaults to YES because
742// a C IOC is deployed onto a machine chosen for it; this crate is just as
743// often linked into a desktop tool, where a silent SCHED_FIFO request is
744// either a guaranteed failure or a way to starve the box.
745// ---------------------------------------------------------------------------
746
747/// Minimum EPICS thread priority (`epicsThreadPriorityMin`).
748pub const PRIORITY_MIN: u8 = 0;
749/// Maximum EPICS thread priority (`epicsThreadPriorityMax`).
750pub const PRIORITY_MAX: u8 = 99;
751
752/// EPICS thread priority — an integer `0..=99` with the named levels
753/// from `epicsThreadPriority*` (`epicsThread.h:73-83`). Lower values
754/// are lower priority; the CA server bands sit below the scan bands so
755/// scan threads preempt CA-server threads on a loaded IOC.
756#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
757pub enum ThreadPriority {
758    /// `epicsThreadPriorityLow` = 10.
759    Low,
760    /// `epicsThreadPriorityCAServerLow` = 20.
761    CaServerLow,
762    /// `epicsThreadPriorityCAServerHigh` = 40.
763    CaServerHigh,
764    /// `epicsThreadPriorityMedium` = 50.
765    Medium,
766    /// `epicsThreadPriorityScanLow` = 60.
767    ScanLow,
768    /// `epicsThreadPriorityScanHigh` = 70.
769    ScanHigh,
770    /// `epicsThreadPriorityHigh` = 90.
771    High,
772    /// `epicsThreadPriorityIocsh` = 91.
773    Iocsh,
774    /// An explicit priority value, clamped to `0..=99` on use.
775    Custom(u8),
776}
777
778impl ThreadPriority {
779    /// The raw EPICS priority value `0..=99`, matching the
780    /// `epicsThreadPriority*` constants in `epicsThread.h`.
781    ///
782    /// `const` so a server can *derive* its band from the named one C derives
783    /// it from — `CaServerLow - 2` rather than a bare `18` with a comment
784    /// asserting the two are the same number. C builds exactly that ladder at
785    /// `caservertask.c:562-575`; restating its output as a literal is how the
786    /// ladder and its constants come to disagree.
787    pub const fn value(self) -> u8 {
788        let v = match self {
789            ThreadPriority::Low => 10,
790            ThreadPriority::CaServerLow => 20,
791            ThreadPriority::CaServerHigh => 40,
792            ThreadPriority::Medium => 50,
793            ThreadPriority::ScanLow => 60,
794            ThreadPriority::ScanHigh => 70,
795            ThreadPriority::High => 90,
796            ThreadPriority::Iocsh => 91,
797            ThreadPriority::Custom(v) => v,
798        };
799        // `Ord::min` is not `const`; the clamp is the same one.
800        if v > PRIORITY_MAX { PRIORITY_MAX } else { v }
801    }
802}
803
804/// Stack-size class — `epicsThreadStackSizeClass` (`epicsThread.h:91`).
805///
806/// The byte size is implementation-dependent in C. These mirror the POSIX
807/// table `STACK_SIZE(f) = f * 0x10000 * sizeof(void*)`
808/// (`libcom/src/osi/os/posix/osdThread.c:506-509`), pointer-width
809/// parameterised exactly as the C macro is: Small = 1, Medium = 2, Big = 4
810/// units of `0x10000 * sizeof(void*)`. On a 64-bit host that is
811/// 512 KiB / 1 MiB / 2 MiB; on `armv7-rtems-eabihf` it is
812/// **256 KiB / 512 KiB / 1 MiB**.
813///
814/// # This is the table a C IOC on RTEMS 6 uses too
815///
816/// Base has a second, much smaller table — 5000 / 8000 / 11000 bytes, floored
817/// at `RTEMS_MINIMUM_STACK_SIZE` — in `os/RTEMS-score/osdThread.c:136-150`.
818/// It does not apply here. `configure/toolchain.c:29-35` selects
819/// `OS_API = posix` for `__RTEMS_MAJOR__ >= 5`, so an RTEMS 6 build searches
820/// `os/RTEMS-posix` then `os/RTEMS`, neither of which contains an
821/// `osdThread.c`, and lands on `os/posix/osdThread.c` — the file above. The
822/// score table is what RTEMS **4/5** used.
823///
824/// Do not "align" these constants with 5000/8000/11000. That would be a
825/// regression against the C IOC we are matching, not a correction: on RTEMS 6
826/// the C IOC asks `pthread_attr_setstacksize` for the POSIX number
827/// (`os/posix/osdThread.c:212-215`), which is the same call `std` makes for
828/// us, with the same argument.
829#[derive(Debug, Clone, Copy, PartialEq, Eq)]
830pub enum StackSizeClass {
831    Small,
832    Medium,
833    Big,
834}
835
836impl StackSizeClass {
837    /// Stack size in bytes for this class, matching the POSIX
838    /// `stackSizeTable` in `osdThread.c` on **every** target.
839    ///
840    /// C's `STACK_SIZE(f) = f * 0x10000 * sizeof(void*)` is parameterised by
841    /// pointer width and so is this, so the two agree by construction rather
842    /// than on one word size: 512 KiB / 1 MiB / 2 MiB on a 64-bit host, and
843    /// 256 KiB / 512 KiB / 1 MiB on `armv7-rtems-eabihf` — which is exactly
844    /// what a C IOC asks `pthread_attr_setstacksize` for on that target.
845    ///
846    /// Read "on a 64-bit target" here before: it was wrong in the direction
847    /// that matters, because the only target this crate is portable *to* is
848    /// 32-bit, and it invited a reader to assume the RTEMS numbers were
849    /// unverified.
850    pub fn bytes(self) -> usize {
851        // STACK_SIZE(f) = f * 0x10000 * sizeof(void*)
852        let unit = 0x10000usize * std::mem::size_of::<usize>();
853        match self {
854            StackSizeClass::Small => unit,
855            StackSizeClass::Medium => 2 * unit,
856            StackSizeClass::Big => 4 * unit,
857        }
858    }
859}
860
861/// Outcome of a best-effort OS-scheduler priority change.
862#[derive(Debug, Clone, Copy, PartialEq, Eq)]
863pub enum PriorityApplied {
864    /// The OS scheduler honoured the requested priority (real-time
865    /// SCHED_FIFO band applied).
866    Realtime,
867    /// Real-time scheduling was never requested: the opt-in switch
868    /// [`RT_PRIORITY_ENV`] is off, so **no scheduler call was made at
869    /// all** and the thread keeps the process default policy.
870    Disabled,
871    /// The platform does not expose a portable scheduler priority API
872    /// (e.g. Windows here, or a non-Unix target) — no change applied.
873    Unsupported,
874    /// The platform exposes the API but rejected the request (typically
875    /// the process lacks `CAP_SYS_NICE`/root for SCHED_FIFO). C's
876    /// `osdThread.c` makes the same best-effort fall back to a non-RT
877    /// thread in this case (`osdThread.c:647` "Try again without
878    /// SCHED_FIFO").
879    BestEffortFailed,
880}
881
882impl PriorityApplied {
883    /// `true` only when the OS actually applied a real-time priority.
884    pub fn is_realtime(self) -> bool {
885        matches!(self, PriorityApplied::Realtime)
886    }
887}
888
889/// Environment switch that opts this process in to real-time (SCHED_FIFO)
890/// scheduling for the IOC threads that carry an EPICS priority.
891///
892/// The switch is read in both directions on every target; what differs is
893/// what it defaults to when unset — see [`DEFAULT_POLICY`].
894///
895/// Accepted "on" values, case-insensitive: `YES`, `TRUE`, `ON`, `1`.
896/// Any other *explicit* value is off.
897///
898/// # Relationship to the C switch
899///
900/// C base has the same concept under
901/// `EPICS_ALLOW_POSIX_THREAD_PRIORITY_SCHEDULING` (`envDefs.h:80`, read at
902/// `osdThread.c:389`), and `envGetBoolConfigParam` (`envSubr.c:331`) accepts
903/// only case-insensitive `yes`. We deliberately do **not** reuse that name:
904/// its base default is `YES` on every target (`configure/CONFIG_ENV:57`)
905/// while ours is `YES` only on RTEMS, so one name would carry two different
906/// defaults on a hosted build depending on which implementation read it.
907pub const RT_PRIORITY_ENV: &str = "EPICS_RS_ALLOW_RT_PRIORITY";
908
909/// Whether this process may ask the OS for real-time scheduling.
910///
911/// Resolved from [`RT_PRIORITY_ENV`] exactly once per process by
912/// [`RtPolicy::current`]. It is a *parameter* of
913/// [`apply_to_current_thread_under`] rather than a check buried inside the
914/// syscall wrapper, so "switch off ⟹ no scheduler call" is a property of
915/// the call graph and not of a runtime branch some future caller can skip.
916#[derive(Debug, Clone, Copy, PartialEq, Eq)]
917pub enum RtPolicy {
918    /// Never touch the OS scheduler.
919    Disabled,
920    /// Best-effort SCHED_FIFO, falling back to default scheduling.
921    AllowRealtime,
922}
923
924/// What [`RT_PRIORITY_ENV`] means when it is **unset**, for the target this
925/// was compiled for.
926///
927/// `AllowRealtime` on RTEMS, `Disabled` everywhere else. The asymmetry is
928/// deliberate and is a property of the default itself — not a `setenv` the
929/// boot shim performs before `main()`. A `setenv` is a runtime side effect any
930/// later caller can undo or reorder, and a variable one component writes for
931/// another to read is the dual-meaning shape this code keeps removing.
932///
933/// Why the two targets differ:
934///
935///   - Base's own equivalent switch,
936///     `EPICS_ALLOW_POSIX_THREAD_PRIORITY_SCHEDULING`, defaults to `YES`
937///     (`configure/CONFIG_ENV:57`). An IOC that honours its priorities is
938///     upstream's default posture, not an opt-in.
939///   - The opt-in gate exists for RT-Linux, where asking for SCHED_FIFO needs
940///     `CAP_SYS_NICE` or a non-zero `RLIMIT_RTPRIO` (so on a desktop the
941///     request merely fails), and where a runaway RT band on a box that
942///     *grants* it can wedge a developer's machine. Neither failure mode
943///     exists on RTEMS: there is no RLIMIT_RTPRIO and no desktop to wedge.
944///   - The band invariant is now a test rather than a hope —
945///     `rtems_priority_map_stays_below_the_libbsd_network_band` proves every
946///     u8 input lands in core 100..199, at or below libbsd's default band and
947///     strictly less urgent than IRQS(96)/TIME(98).
948///
949/// An explicit value still wins in **both** directions on both targets, so
950/// `EPICS_RS_ALLOW_RT_PRIORITY=NO` turns it off on RTEMS.
951pub const DEFAULT_POLICY: RtPolicy = default_policy(cfg!(target_os = "rtems"));
952
953/// [`DEFAULT_POLICY`] as a pure function of the one target fact it depends
954/// on, so both arms are reachable from a host test run. A host CI will never
955/// execute the RTEMS arm otherwise, and an untested default is exactly the
956/// kind that drifts.
957const fn default_policy(on_rtems: bool) -> RtPolicy {
958    if on_rtems {
959        RtPolicy::AllowRealtime
960    } else {
961        RtPolicy::Disabled
962    }
963}
964
965impl RtPolicy {
966    /// Parse a raw switch value (`None` = unset ⇒ [`DEFAULT_POLICY`]).
967    pub fn from_env_value(raw: Option<&str>) -> RtPolicy {
968        Self::resolve(raw, DEFAULT_POLICY)
969    }
970
971    /// [`Self::from_env_value`] with the unset-default injected, so a host
972    /// test can ask what an RTEMS process would do with the same input.
973    pub fn resolve(raw: Option<&str>, default: RtPolicy) -> RtPolicy {
974        let Some(raw) = raw else {
975            return default;
976        };
977        let v = raw.trim();
978        let on = v.eq_ignore_ascii_case("yes")
979            || v.eq_ignore_ascii_case("true")
980            || v.eq_ignore_ascii_case("on")
981            || v == "1";
982        if on {
983            RtPolicy::AllowRealtime
984        } else {
985            RtPolicy::Disabled
986        }
987    }
988
989    /// The process-wide policy, read from [`RT_PRIORITY_ENV`] on first use
990    /// and cached. Caching matches C, which resolves its switch once in
991    /// `epicsThreadInit` (`osdThread.c:389`), and keeps later `set_var`
992    /// calls from changing the scheduling of threads already running.
993    pub fn current() -> RtPolicy {
994        static POLICY: std::sync::OnceLock<RtPolicy> = std::sync::OnceLock::new();
995        *POLICY.get_or_init(|| {
996            RtPolicy::from_env_value(std::env::var(RT_PRIORITY_ENV).ok().as_deref())
997        })
998    }
999}
1000
1001/// Apply an EPICS [`ThreadPriority`] to the **current OS thread**, best
1002/// effort.
1003///
1004/// C parity: mirrors `osdThread.c`'s SCHED_FIFO mapping
1005/// `oss = p * (max-min)/100 + min` over the kernel's
1006/// `sched_get_priority_min/max(SCHED_FIFO)` range, and the
1007/// EPERM-fallback to a non-RT thread.
1008///
1009/// Returns [`PriorityApplied`] describing what the platform allowed —
1010/// callers running in environments without RT permission still get a
1011/// running thread, just at the default policy, exactly as a C IOC does.
1012///
1013/// Note: tokio tasks spawned via [`spawn`] share worker threads, so
1014/// this is meaningful for [`spawn_blocking`] closures and for tuning
1015/// the runtime's worker threads at startup — not for individual async
1016/// tasks.
1017///
1018/// Platform support: the OS-scheduler change is wired on Linux, via the
1019/// range-probed linear map of `os/posix/osdThread.c`, and on RTEMS, via the
1020/// fixed map of `os/RTEMS-score/osdThread.c` inverted into POSIX space (see
1021/// `map_epics_priority_rtems` — the two maps differ in shape, deliberately).
1022/// On other targets the priority enum + API surface still exist but `apply`
1023/// reports [`PriorityApplied::Unsupported`] — no band has been measured there.
1024///
1025/// Opt-in: real-time scheduling is only ever requested when
1026/// [`RT_PRIORITY_ENV`] is set (see [`RtPolicy`]). With the switch off this
1027/// returns [`PriorityApplied::Disabled`] without calling the OS at all.
1028pub fn apply_to_current_thread(priority: ThreadPriority) -> PriorityApplied {
1029    apply_to_current_thread_under(RtPolicy::current(), priority)
1030}
1031
1032/// [`apply_to_current_thread`] with the real-time policy supplied by the
1033/// caller instead of read from the environment.
1034///
1035/// The single gate: [`RtPolicy::Disabled`] returns before any scheduler
1036/// call is reachable. Exposed so a caller that already owns its RT policy
1037/// (and the tests that must exercise both states in one process, since
1038/// [`RtPolicy::current`] is cached) does not have to mutate the environment.
1039pub fn apply_to_current_thread_under(
1040    policy: RtPolicy,
1041    priority: ThreadPriority,
1042) -> PriorityApplied {
1043    match policy {
1044        RtPolicy::Disabled => PriorityApplied::Disabled,
1045        RtPolicy::AllowRealtime => apply_priority_impl(priority.value()),
1046    }
1047}
1048
1049/// The prologue an IOC thread runs as its first statement, when it takes on
1050/// its role: publish its name to the OS, then request its scheduling band.
1051///
1052/// Two things a thread owes the operator, and they have different gates.
1053/// The band is opt-in ([`RT_PRIORITY_ENV`]) and best effort. The **name** is
1054/// unconditional: a thread that cannot be identified in a task listing
1055/// cannot be diagnosed, and on RTEMS that listing is often the only
1056/// instrument there is — bring-up had to measure libbsd's priority band by
1057/// other means precisely because none of our threads carried a name the
1058/// kernel could show.
1059///
1060/// Use this rather than [`apply_to_current_thread`] at a thread's entry, so
1061/// naming cannot be forgotten by the next thread somebody adds. Call
1062/// [`apply_to_current_thread`] directly only when re-banding a thread that
1063/// is already named and running. A thread that deliberately takes no EPICS
1064/// band — the iocsh script runners — calls [`name_current_thread`] alone
1065/// rather than inventing a priority just to be visible.
1066///
1067/// The band this asks the OS for is also what orders the blocking locks in
1068/// `server::database::record_lock` and its siblings: they are
1069/// priority-inheritance mutexes, so the wait queue is the *kernel's* and it
1070/// is ranked by the scheduling priority requested here. With
1071/// [`RtPolicy::Disabled`] no scheduler call happens and there is no ordering
1072/// to have — the hosted default, where the locks still exclude but do not
1073/// prioritise ([`crate::runtime::sync::is_pi_mutex_active`]).
1074pub fn enter_ioc_thread(priority: ThreadPriority) -> PriorityApplied {
1075    name_current_thread();
1076    apply_to_current_thread(priority)
1077}
1078
1079/// Push `std::thread::current().name()` down to the OS thread object.
1080///
1081/// No-op off RTEMS: `std` already calls the platform's `pthread_setname_np`
1082/// from `Builder::spawn` on every hosted target it supports. RTEMS is not in
1083/// that list, so a name set with `Builder::name` lives only in Rust's own
1084/// `Thread` struct and never reaches the kernel — which is what makes our
1085/// threads invisible to an RTEMS task listing.
1086#[cfg(not(target_os = "rtems"))]
1087pub fn name_current_thread() {}
1088
1089/// RTEMS: `pthread_setname_np` (`cpukit/posix/src/pthreadsetnamenp.c`) into
1090/// `_Thread_Set_name`, which `strlcpy`s into `_Thread_Maximum_name_size`.
1091///
1092/// That size is `CONFIGURE_MAXIMUM_THREAD_NAME_SIZE`, default **16**
1093/// including the NUL (`rtems/score/thread.h:1079`,
1094/// `rtems/confdefs/threads.h:92-93`), and the boot shim does not override
1095/// it — so 15 usable bytes, the same budget `std` truncates to on Linux
1096/// (`TASK_COMM_LEN`). Truncating here rather than letting the kernel do it
1097/// keeps that existing rule and keeps the call's success unambiguous:
1098/// `_Thread_Set_name` still *sets* an over-long name, it just also returns
1099/// `STATUS_RESULT_TOO_LARGE` → `ERANGE`, so an untruncated call would report
1100/// failure for a name it had in fact applied.
1101#[cfg(target_os = "rtems")]
1102pub fn name_current_thread() {
1103    let current = std::thread::current();
1104    let Some(name) = current.name() else {
1105        return;
1106    };
1107    let Ok(c_name) = std::ffi::CString::new(truncate_thread_name(name)) else {
1108        // An interior NUL cannot come from `Builder::name`, which takes a
1109        // `String`; nothing to publish if one ever did.
1110        return;
1111    };
1112    // SAFETY: `pthread_setname_np` acts on the calling thread and reads a
1113    // NUL-terminated string that outlives the call.
1114    let rc =
1115        unsafe { rtems_sched::pthread_setname_np(rtems_sched::pthread_self(), c_name.as_ptr()) };
1116    if rc != 0 {
1117        tracing::debug!(
1118            target: "epics_base_rs::runtime",
1119            thread = name,
1120            errno = rc,
1121            "pthread_setname_np failed; thread stays unnamed in the task listing"
1122        );
1123    }
1124}
1125
1126/// `CONFIGURE_MAXIMUM_THREAD_NAME_SIZE` (default 16) minus the NUL.
1127#[cfg(any(target_os = "rtems", test))]
1128const RTEMS_MAX_THREAD_NAME_BYTES: usize = 15;
1129
1130/// Cut a thread name to what an RTEMS thread object can hold, on a UTF-8
1131/// boundary.
1132///
1133/// Byte budget, not character count — `_Thread_Set_name` `strlcpy`s bytes —
1134/// but never mid-codepoint, or the task listing shows invalid UTF-8. Kept as
1135/// a pure function so the rule is testable on the host, where the caller
1136/// that applies it does not exist.
1137#[cfg(any(target_os = "rtems", test))]
1138fn truncate_thread_name(name: &str) -> &str {
1139    let mut end = name.len().min(RTEMS_MAX_THREAD_NAME_BYTES);
1140    while end > 0 && !name.is_char_boundary(end) {
1141        end -= 1;
1142    }
1143    &name[..end]
1144}
1145
1146/// The SCHED_FIFO priority range this process may actually enter.
1147///
1148/// C parity: `find_pri_range` (`osdThread.c:259-314`). The kernel's
1149/// `sched_get_priority_max` reports the *policy's* range and ignores
1150/// `RLIMIT_RTPRIO`, so on an RT box with a restricted limit the nominal
1151/// range is wider than the usable one; C binary-searches for the real
1152/// ceiling and so do we.
1153#[cfg(target_os = "linux")]
1154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1155enum RtRange {
1156    /// The kernel does not report a SCHED_FIFO range at all.
1157    Unsupported,
1158    /// The range exists but this process may not enter it — no
1159    /// `CAP_SYS_NICE` and `RLIMIT_RTPRIO` is 0. C's equivalent is
1160    /// `usePolicy == 0` (`osdThread.c:279-285`, `:331`), which makes it
1161    /// stop asking for SCHED_FIFO for the life of the process.
1162    Denied,
1163    /// Priorities `min..=max` are settable.
1164    Available { min: i32, max: i32 },
1165}
1166
1167/// Probe once per process and cache. Only reachable on the
1168/// [`RtPolicy::AllowRealtime`] path, so a default (switch-off) process
1169/// never runs the probe and never makes a scheduler call.
1170#[cfg(target_os = "linux")]
1171fn permitted_fifo_range() -> RtRange {
1172    static RANGE: std::sync::OnceLock<RtRange> = std::sync::OnceLock::new();
1173    *RANGE.get_or_init(probe_fifo_range)
1174}
1175
1176#[cfg(target_os = "linux")]
1177fn probe_fifo_range() -> RtRange {
1178    // SAFETY: sched_get_priority_min/max take only an int policy and have
1179    // no preconditions.
1180    let (min, max) = unsafe {
1181        (
1182            libc::sched_get_priority_min(libc::SCHED_FIFO),
1183            libc::sched_get_priority_max(libc::SCHED_FIFO),
1184        )
1185    };
1186    if min < 0 || max < 0 || max < min {
1187        return RtRange::Unsupported;
1188    }
1189
1190    // The probe *changes the scheduling of the thread that runs it*, so it
1191    // runs on a throwaway thread — exactly why C hands `find_pri_range` to
1192    // its own `pthread_create`/`pthread_join` pair (`osdThread.c:316-334`).
1193    let probe = std::thread::Builder::new()
1194        .name("cbRtProbe".to_string())
1195        // Two `sched_get_priority_*` calls and a `sched_setscheduler`; nothing
1196        // recurses. Linux-only, so this is not the RTEMS ceiling — but there is
1197        // no reason for a throwaway probe to reserve 2 MiB on any target.
1198        .stack_size(StackSizeClass::Small.bytes())
1199        .spawn(move || {
1200            // `osdThread.c:277-287`: failing at the minimum means no
1201            // permission for SCHED_FIFO at all.
1202            if set_fifo_priority(min) != 0 {
1203                return RtRange::Denied;
1204            }
1205            // `osdThread.c:296-307`: binary-search the real ceiling.
1206            let (mut low, mut high) = (min, max);
1207            while low < high {
1208                let mid = (high + low) / 2;
1209                if set_fifo_priority(mid) != 0 {
1210                    high = mid;
1211                } else {
1212                    low = mid + 1;
1213                }
1214            }
1215            // `osdThread.c:310`: `max_pri = try_pri(max) ? max-1 : max`.
1216            let top = if set_fifo_priority(high) != 0 {
1217                high - 1
1218            } else {
1219                high
1220            };
1221            RtRange::Available { min, max: top }
1222        });
1223    match probe.map(std::thread::JoinHandle::join) {
1224        Ok(Ok(range)) => range,
1225        // Cannot spawn, or the probe died: treat as no RT rather than
1226        // guessing a range we have not shown to be settable.
1227        _ => RtRange::Denied,
1228    }
1229}
1230
1231/// Map an EPICS priority `0..=99` onto the permitted SCHED_FIFO range.
1232///
1233/// C parity: `epicsThreadGetPosixPriority` (`osdThread.c:129-144`) — the
1234/// POSIX counterpart of the `epicsThreadGetOssPriorityValue` used on
1235/// RTEMS/vxWorks (`RTEMS-score/osdThread.c:94`, `vxWorks/osdThread.c:99`).
1236///
1237/// **Hosted only.** RTEMS deliberately does not use this map — see
1238/// [`map_epics_priority_rtems`] for the shape and the reason. The `test`
1239/// arm of the cfg exists so the two maps can be compared in one process
1240/// on the host; without it the divergence test would silently vanish.
1241#[cfg(any(target_os = "linux", test))]
1242fn map_epics_priority(epics_priority: u8, min: i32, max: i32) -> i32 {
1243    // `osdThread.c:133-134`: a degenerate range collapses to one level.
1244    if max == min {
1245        return max;
1246    }
1247    let slope = (max - min) as f64 / 100.0;
1248    let oss = epics_priority as f64 * slope + min as f64;
1249    // `ThreadPriority::value` caps at 99 and the slope is over 100, so this
1250    // cannot exceed `max`; the clamp guards the probed bounds, which are
1251    // runtime values rather than compile-time constants.
1252    (oss as i32).clamp(min, max)
1253}
1254
1255/// The highest RTEMS *core* priority number, i.e. the least urgent level.
1256///
1257/// Measured on the bring-up guest (RTEMS 6 + libbsd, QEMU
1258/// `xilinx_zynq_a9`): `RTEMS_MAXIMUM_PRIORITY == 255`, the idle thread runs
1259/// at core 255, and `sched_get_priority_min/max(SCHED_FIFO)` report `1`/`254`.
1260/// The POSIX-to-core inversion `core = 255 - posix` was verified in both
1261/// directions on that guest.
1262#[cfg(any(target_os = "rtems", test))]
1263const RTEMS_MAXIMUM_PRIORITY: i32 = 255;
1264
1265/// The RTEMS *core* priority an EPICS priority must land on.
1266///
1267/// Verbatim `epicsThreadGetOssPriorityValue` from EPICS's own RTEMS port,
1268/// `libcom/src/osi/os/RTEMS-score/osdThread.c:94-102`:
1269///
1270/// ```c
1271/// int epicsThreadGetOssPriorityValue(unsigned int osiPriority)
1272/// {
1273///     if (osiPriority > 99) { return 100; }
1274///     else { return (199 - (signed int)osiPriority); }
1275/// }
1276/// ```
1277///
1278/// Fixed offsets, not a range-scaled slope. The whole EPICS space therefore
1279/// occupies core `100..=199` and nothing else can be reached — which is the
1280/// property [`map_epics_priority_rtems`] is chosen for.
1281#[cfg(any(target_os = "rtems", test))]
1282const fn rtems_core_priority(epics_priority: u8) -> i32 {
1283    if epics_priority > 99 {
1284        100
1285    } else {
1286        199 - epics_priority as i32
1287    }
1288}
1289
1290/// Map an EPICS priority onto an RTEMS **POSIX** SCHED_FIFO priority.
1291///
1292/// A distinct function from [`map_epics_priority`] on purpose: the two have
1293/// different *shapes*, not different endpoints. Expressing this one as the
1294/// hosted linear map with `min`/`max` retuned would re-introduce the linear
1295/// map the moment somebody adjusted a constant, and the linear map is the
1296/// thing this arm exists to avoid.
1297///
1298/// **Deliberate deviation from base-on-RTEMS-6.** EPICS base compiles
1299/// `os/posix/osdThread.c` on RTEMS 6 — `configure/toolchain.c:31-36` sets
1300/// `OS_API = posix` for `__RTEMS_MAJOR__ >= 5`, and `os/RTEMS-posix/` ships
1301/// no `osdThread.c` — so upstream applies the *linear* map
1302/// `oss = epics*(max-min)/100 + min` over `find_pri_range`'s result, which
1303/// on this guest is `min=1`/`max=254`, with
1304/// `EPICS_ALLOW_POSIX_THREAD_PRIORITY_SCHEDULING` defaulting to `YES`
1305/// (`configure/CONFIG_ENV:57`). That places EPICS 91 (the CA server band) at
1306/// posix 231, i.e. **core 24** — far above libbsd's network threads. The
1307/// crossover is EPICS **63** (posix 160, core 95): every EPICS priority at or
1308/// above it outranks the interrupt server. Reproducing that would reproduce
1309/// the hazard, so this port takes EPICS's *own* RTEMS answer instead —
1310/// [`rtems_core_priority`] — and inverts it into the POSIX space we actually
1311/// set:
1312///
1313/// ```text
1314/// core = RTEMS_MAXIMUM_PRIORITY - posix   (measured)
1315/// core = 199 - epics                      (RTEMS-score/osdThread.c:94-102)
1316/// ⟹ posix = 255 - (199 - epics) = 56 + epics
1317/// ```
1318///
1319/// So EPICS 0 → posix 56 → core 199, EPICS 99 → posix 155 → core 100, and
1320/// anything above 99 clamps to posix 155. Every value is inside the guest's
1321/// settable `[1, 254]`. **Measured on target**, core 100 is also where
1322/// libbsd's own twelve default-band worker threads sit, so the map's most
1323/// urgent reachable value *ties* libbsd's default band there rather than
1324/// staying strictly below it — a boundary tie by construction, not a
1325/// collision-free image. It is still strictly below `IRQS`(96)/`TIME`(98);
1326/// see `rtems_priority_map_stays_below_the_libbsd_network_band`, which
1327/// asserts the non-strict `core >= 100` this tie actually produces.
1328#[cfg(any(target_os = "rtems", test))]
1329fn map_epics_priority_rtems(epics_priority: u8) -> i32 {
1330    RTEMS_MAXIMUM_PRIORITY - rtems_core_priority(epics_priority)
1331}
1332
1333/// Ask the OS for SCHED_FIFO at `oss` on the **calling** thread. The single
1334/// place this crate touches the scheduler; returns the raw `pthread_*`
1335/// status (0 on success). Counting lives here so the "switch off ⟹ no
1336/// scheduler call" guarantee is observable on every target that has one.
1337#[cfg(any(target_os = "linux", target_os = "rtems"))]
1338fn set_fifo_priority(oss: i32) -> i32 {
1339    SCHED_CALLS_MADE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1340    #[cfg(test)]
1341    SCHED_CALLS.with(|c| c.set(c.get() + 1));
1342    set_fifo_priority_raw(oss)
1343}
1344
1345#[cfg(target_os = "linux")]
1346fn set_fifo_priority_raw(oss: i32) -> i32 {
1347    let param = libc::sched_param {
1348        sched_priority: oss,
1349    };
1350    // SAFETY: pthread_setschedparam operates on the calling thread with a
1351    // stack-local sched_param and a valid policy constant.
1352    unsafe { libc::pthread_setschedparam(libc::pthread_self(), libc::SCHED_FIFO, &param) }
1353}
1354
1355/// The RTEMS scheduler surface, declared here rather than taken from `libc`.
1356///
1357/// `libc`'s `newlib/rtems` module (0.2.188) declares neither `sched_param`,
1358/// `SCHED_FIFO`, `pthread_setschedparam` nor `pthread_self` — its sibling
1359/// newlib targets `vita` and `horizon` declare all of them, RTEMS does not.
1360/// The functions exist in the RTEMS 6 kernel
1361/// (`cpukit/posix/src/pthreadsetschedparam.c`) and in the toolchain headers;
1362/// only the Rust binding is missing.
1363///
1364/// `libc::timespec` is deliberately NOT used to describe the sporadic-server
1365/// tail: `libc` types `time_t` as `i32` for every newlib target except
1366/// `horizon`/`espidf` (`src/unix/newlib/mod.rs:55-64`), while the arm-rtems6
1367/// toolchain has `sizeof(time_t) == 8`. Its `timespec` is therefore half the
1368/// real width on this target, so the tail is carried as opaque bytes sized
1369/// from the target compiler instead.
1370#[cfg(target_os = "rtems")]
1371mod rtems_sched {
1372    use std::ffi::c_int;
1373
1374    /// `sys/sched.h`: `#define SCHED_FIFO 1`.
1375    pub const SCHED_FIFO: c_int = 1;
1376
1377    /// `struct sched_param` as arm-rtems6 lays it out.
1378    ///
1379    /// `sys/features.h:404-405` defines both `_POSIX_SPORADIC_SERVER` and
1380    /// `_POSIX_THREAD_SPORADIC_SERVER`, so `sys/sched.h` compiles the
1381    /// sporadic-server tail in. Measured with the target compiler
1382    /// (`arm-rtems6-gcc`, `sizeof`/`offsetof` via array-length symbols):
1383    ///
1384    /// | field | offset | size |
1385    /// |-------|--------|------|
1386    /// | `sched_priority`        |  0 |  4 |
1387    /// | `sched_ss_low_priority` |  4 |  4 |
1388    /// | `sched_ss_repl_period`  |  8 | 16 |
1389    /// | `sched_ss_init_budget`  | 24 | 16 |
1390    /// | `sched_ss_max_repl`     | 40 |  4 |
1391    ///
1392    /// total 48, align 8. `SCHED_FIFO` makes the kernel read only
1393    /// `sched_priority` (`_POSIX_Thread_Translate_sched_param` takes the
1394    /// sporadic branch for `SCHED_SPORADIC` alone), but the struct is
1395    /// declared at full width anyway so the kernel is never handed a pointer
1396    /// to less memory than its own header describes.
1397    #[repr(C, align(8))]
1398    pub struct SchedParam {
1399        pub sched_priority: c_int,
1400        /// Offsets 4..48 — the sporadic-server fields, unused under
1401        /// `SCHED_FIFO` and always zeroed.
1402        pub sporadic_tail: [u8; 44],
1403    }
1404
1405    // The whole point of the opaque tail is that the width is right. If a
1406    // future edit reaches for `libc::timespec` here, this stops the build
1407    // instead of silently handing the kernel a short buffer.
1408    const _: () = {
1409        assert!(core::mem::size_of::<SchedParam>() == 48);
1410        assert!(core::mem::align_of::<SchedParam>() == 8);
1411    };
1412
1413    unsafe extern "C" {
1414        pub fn pthread_self() -> libc::pthread_t;
1415        pub fn pthread_setschedparam(
1416            thread: libc::pthread_t,
1417            policy: c_int,
1418            param: *const SchedParam,
1419        ) -> c_int;
1420        /// `cpukit/posix/src/pthreadsetnamenp.c`. Also absent from `libc`'s
1421        /// `newlib/rtems` module.
1422        pub fn pthread_setname_np(thread: libc::pthread_t, name: *const std::ffi::c_char) -> c_int;
1423    }
1424}
1425
1426#[cfg(target_os = "rtems")]
1427fn set_fifo_priority_raw(oss: i32) -> i32 {
1428    let param = rtems_sched::SchedParam {
1429        sched_priority: oss,
1430        sporadic_tail: [0u8; 44],
1431    };
1432    // SAFETY: `pthread_setschedparam` acts on the calling thread, is handed a
1433    // stack-local `sched_param` of the target's own width (asserted above),
1434    // and a policy constant taken from `sys/sched.h`.
1435    unsafe {
1436        rtems_sched::pthread_setschedparam(
1437            rtems_sched::pthread_self(),
1438            rtems_sched::SCHED_FIFO,
1439            &param,
1440        )
1441    }
1442}
1443
1444/// The unprivileged-fallback message. Emitted **once per process**: the
1445/// denial is a property of the process, not of the thread that happened to
1446/// notice it first, and an IOC creates a thread per CA client.
1447#[cfg(target_os = "linux")]
1448fn warn_rt_denied_once() {
1449    static WARNED: std::sync::Once = std::sync::Once::new();
1450    WARNED.call_once(|| {
1451        #[cfg(test)]
1452        DENIED_WARNINGS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1453        tracing::warn!(
1454            target: "epics_base_rs::runtime",
1455            switch = RT_PRIORITY_ENV,
1456            "{RT_PRIORITY_ENV} asked for real-time scheduling, but this process may not \
1457             use SCHED_FIFO (needs CAP_SYS_NICE or a non-zero RLIMIT_RTPRIO). Every IOC \
1458             thread stays at the default scheduling policy; timing is not real-time. \
1459             Logged once per process."
1460        );
1461    });
1462}
1463
1464#[cfg(target_os = "linux")]
1465fn apply_priority_impl(epics_priority: u8) -> PriorityApplied {
1466    let (min, max) = match permitted_fifo_range() {
1467        RtRange::Unsupported => return PriorityApplied::Unsupported,
1468        RtRange::Denied => {
1469            warn_rt_denied_once();
1470            return PriorityApplied::BestEffortFailed;
1471        }
1472        RtRange::Available { min, max } => (min, max),
1473    };
1474    let oss = map_epics_priority(epics_priority, min, max);
1475    let rc = set_fifo_priority(oss);
1476    if rc == 0 {
1477        PriorityApplied::Realtime
1478    } else {
1479        // The probe proved this range settable, so a failure here is not
1480        // the permission case the warning covers — keep it at debug.
1481        tracing::debug!(
1482            target: "epics_base_rs::runtime",
1483            epics_priority,
1484            oss,
1485            errno = rc,
1486            "SCHED_FIFO priority not applied; thread stays at default policy"
1487        );
1488        PriorityApplied::BestEffortFailed
1489    }
1490}
1491
1492#[cfg(target_os = "rtems")]
1493fn apply_priority_impl(epics_priority: u8) -> PriorityApplied {
1494    // Without this, every IOC thread runs at one level just above idle:
1495    // `cpukit/posix/src/pthreadattrdefault.c:49-58` sets
1496    // `inheritsched = PTHREAD_INHERIT_SCHED` in the default attribute set and
1497    // `std` never calls `pthread_attr_setinheritsched`, so a thread inherits
1498    // its creator's parameters — and every IOC thread descends from
1499    // `POSIX_Init`, which the boot shim deliberately lowers to
1500    // `RTEMS_MAXIMUM_PRIORITY - 1`. The CA receiver/sender ordering that stops
1501    // a stalled client starving command dispatch does not hold at one level.
1502    //
1503    // No range probe, unlike Linux. The probe exists there because
1504    // `sched_get_priority_max` reports the *policy's* range while
1505    // `RLIMIT_RTPRIO`/`CAP_SYS_NICE` decide the usable one, so the settable
1506    // ceiling has to be searched for. RTEMS has no such permission gate —
1507    // `pthread_setschedparam` (`cpukit/posix/src/pthreadsetschedparam.c`)
1508    // performs no privilege check — and this map's image is a fixed
1509    // `[56, 155]`, inside the measured settable `[1, 254]` by construction.
1510    // There is nothing to discover, and a probe thread would itself need a
1511    // band to run in.
1512    let oss = map_epics_priority_rtems(epics_priority);
1513    let rc = set_fifo_priority(oss);
1514    if rc == 0 {
1515        PriorityApplied::Realtime
1516    } else {
1517        tracing::debug!(
1518            target: "epics_base_rs::runtime",
1519            epics_priority,
1520            oss,
1521            errno = rc,
1522            "SCHED_FIFO priority not applied; thread stays at default policy"
1523        );
1524        PriorityApplied::BestEffortFailed
1525    }
1526}
1527
1528#[cfg(not(any(target_os = "linux", target_os = "rtems")))]
1529fn apply_priority_impl(_epics_priority: u8) -> PriorityApplied {
1530    // No OS-scheduler priority API is wired on other targets. The two that
1531    // are wired each needed a *measured* target band before they could be:
1532    // Linux probes for its settable ceiling at runtime, and RTEMS's map is
1533    // pinned against libbsd's network-thread band measured on the bring-up
1534    // guest. Neither number is guessable, so a new target gets `Unsupported`
1535    // until somebody measures it rather than a plausible-looking range.
1536    PriorityApplied::Unsupported
1537}
1538
1539/// How many times this process has asked the OS scheduler for SCHED_FIFO,
1540/// across every thread — including the one-off range probe.
1541///
1542/// The observable form of the opt-in guarantee: with [`RT_PRIORITY_ENV`]
1543/// unset, a process can run its whole life and this stays `0`. Also answers
1544/// "did this IOC ever actually try to go real-time?" from a log line.
1545///
1546/// Always `0` off Linux, where no scheduler call is wired at all.
1547pub fn sched_calls_made() -> usize {
1548    SCHED_CALLS_MADE.load(std::sync::atomic::Ordering::Relaxed)
1549}
1550
1551static SCHED_CALLS_MADE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1552
1553#[cfg(all(test, any(target_os = "linux", target_os = "rtems")))]
1554thread_local! {
1555    /// Every scheduler call this crate makes passes through
1556    /// [`set_fifo_priority`], which bumps this. Tests assert the delta is
1557    /// zero with the switch off — the property "switch off ⟹ no sched
1558    /// calls" observed directly rather than inferred from a return value.
1559    ///
1560    /// Per-thread, not global: `pthread_setschedparam` acts on the calling
1561    /// thread, so a per-thread count is the exact quantity, and a test
1562    /// cannot be perturbed by a concurrent one (the unit tests share a
1563    /// process under plain `cargo test`).
1564    static SCHED_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1565}
1566
1567/// How many times the once-per-process denial warning was emitted.
1568#[cfg(all(test, target_os = "linux"))]
1569static DENIED_WARNINGS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1570
1571/// Spawn a blocking closure on a dedicated thread and apply the given
1572/// EPICS [`ThreadPriority`] to that thread before running `f`.
1573///
1574/// The priority application is best effort (see
1575/// [`apply_to_current_thread`]); `f` runs regardless of whether the OS
1576/// honoured the request. This is the priority-aware counterpart of
1577/// [`spawn_blocking`] for IOC threads (CA server, scan) that a C IOC
1578/// would run in a distinct SCHED band.
1579#[cfg(tokio_backend)]
1580pub fn spawn_blocking_with_priority<F, R>(priority: ThreadPriority, f: F) -> TaskHandle<R>
1581where
1582    F: FnOnce() -> R + Send + 'static,
1583    R: Send + 'static,
1584{
1585    tokio::task::spawn_blocking(move || {
1586        let _ = enter_ioc_thread(priority);
1587        f()
1588    })
1589}
1590
1591/// RTEMS: run the blocking closure on a callback-pool worker.
1592///
1593/// The requested EPICS [`ThreadPriority`] is **not** yet mapped onto a callback
1594/// band here: the pool workers are long-lived and shared, so re-prioritising the
1595/// running worker per task would leak that priority into the next callback it
1596/// drains. The closure runs at the pool's default Medium band; mapping
1597/// `ThreadPriority` to a `CallbackPriority` band is deferred to RTEMS bring-up.
1598#[cfg(exec_backend)]
1599pub fn spawn_blocking_with_priority<F, R>(_priority: ThreadPriority, f: F) -> TaskHandle<R>
1600where
1601    F: FnOnce() -> R + Send + 'static,
1602    R: Send + 'static,
1603{
1604    use crate::runtime::background::future_exec::{DEFAULT_SPAWN_PRIORITY, spawn_blocking_on};
1605    spawn_blocking_on(
1606        &background().callbacks().handle(),
1607        DEFAULT_SPAWN_PRIORITY,
1608        f,
1609    )
1610}
1611
1612/// Spawn a **dedicated OS thread** that runs `f` at `priority` with a `stack`
1613/// of [`StackSizeClass`], plus whatever ambient async context
1614/// [`block_on_sync`] needs on this target.
1615///
1616/// # Why the stack class is a parameter and not a default
1617///
1618/// C creates an IOC thread with `epicsThreadCreate(name, priority, stackSize,
1619/// fn, arg)` — three attributes. This seam carried the first two and let the
1620/// third fall through to whatever `std` picks, which is **2 MiB on RTEMS**:
1621/// `std/src/sys/thread/unix.rs` gates its `DEFAULT_MIN_STACK_SIZE` on
1622/// `not(any(l4re, vxworks, espidf, nuttx))`, and vxWorks got a 256 KiB
1623/// carve-out where RTEMS did not.
1624///
1625/// That is invisible on the host, where a thread stack is lazily-committed
1626/// virtual address space, and decisive on the target, where it is carved
1627/// eagerly out of a fixed pool. Making it a parameter is what stops a new
1628/// per-connection thread from silently costing 2 MiB: there is no default to
1629/// inherit, so every caller states what the thread is for.
1630///
1631/// Not [`spawn_blocking_with_priority`], and the difference is the point.
1632/// That one hands the closure to a *pool*: tokio's blocking pool on the host,
1633/// and on RTEMS a shared callback-pool worker that also drops the priority
1634/// (see its `exec_backend` arm). Both are right for work that finishes. A
1635/// server thread that lives as long as its connection would occupy a pool
1636/// worker for that whole time, so the pool is the wrong home for it — an IOC
1637/// thread that a C IOC would create with `epicsThreadCreate` wants a thread of
1638/// its own, and the priority a C IOC gives it.
1639///
1640/// # Why the ambient context is part of this, and not the caller's problem
1641///
1642/// `block_on_sync` picks its mechanism from the thread it is called on, but it
1643/// cannot *create* the context a future needs. On the host a fresh
1644/// `std::thread` has no runtime, so a future that spawns tasks or arms timers
1645/// panics with "there is no reactor running" the moment it is polled — even
1646/// though `block_on_sync` itself was perfectly happy to park. On RTEMS the
1647/// exec backend is process-global (`background_init`), so a bare thread is
1648/// already complete. That asymmetry is a property of the two backends, so it
1649/// is resolved here, at the seam, rather than by a `cfg` in every server that
1650/// wants a thread.
1651///
1652/// The captured context is whatever the *calling* thread is running under, so
1653/// call this from the runtime the work should belong to. When there is none
1654/// (RTEMS always; on the host a caller that is itself outside a runtime) the
1655/// thread simply runs without one, which is exactly right for a future whose
1656/// awaits are all runtime-agnostic.
1657///
1658/// # A current-thread ambient is not inherited, and that is the rule
1659///
1660/// [`RuntimeHandle::try_current`] answers two different questions with one
1661/// value: *"am I running on this runtime's thread"* and *"has this thread
1662/// merely entered this handle"*. [`block_on_sync`] cannot distinguish them, so
1663/// it must assume the first and refuse to park under a `CurrentThread` flavor —
1664/// correct on that runtime's own thread, where parking halts the task that
1665/// would wake you, and wrong on a dedicated thread, where it halts nothing.
1666///
1667/// So the dual meaning is removed here, at the one place a dedicated thread's
1668/// context is decided, rather than left for `block_on_sync` to guess: a
1669/// `CurrentThread` ambient is **not** inherited, and the thread runs with no
1670/// runtime — the `park_on` arm, which is sound for it and is the only arm RTEMS
1671/// ever takes.
1672///
1673/// Nothing is lost by declining it. What inheriting buys is stated above —
1674/// `spawn` and the timer inside `block_on_sync` — and under a `CurrentThread`
1675/// ambient `block_on_sync` returns
1676/// [`Err(CurrentThreadRuntime)`](NotBlockable::CurrentThreadRuntime), so every one of those
1677/// powers is unreachable anyway. Inheriting it can only convert a thread that
1678/// would have worked into one that cannot block at all. Measured as exactly
1679/// that: the PVA client's blocking byte pumps
1680/// (`runtime::blocking_io::spawn_pump`) are dedicated threads whose bodies are
1681/// pure `tokio::sync` channel traffic, and every `#[tokio::test]` that drives
1682/// one is `CurrentThread` by default — inheritance made the reader pump exit on
1683/// its first chunk and the connection read as "server closed during handshake".
1684#[cfg(tokio_backend)]
1685pub fn spawn_dedicated_thread<F>(
1686    name: String,
1687    priority: ThreadPriority,
1688    stack: StackSizeClass,
1689    f: F,
1690) -> std::io::Result<std::thread::JoinHandle<()>>
1691where
1692    F: FnOnce() + Send + 'static,
1693{
1694    let ambient = InheritedRuntime::capture();
1695    std::thread::Builder::new()
1696        .name(name)
1697        .stack_size(stack.bytes())
1698        .spawn(move || {
1699            // Held for the whole body: it is what makes `tokio::spawn` and the
1700            // timer reachable from this thread, and therefore what lets a future
1701            // written for the hosted driver run unchanged under `block_on_sync`.
1702            ambient.run(move || {
1703                let _ = enter_ioc_thread(priority);
1704                f()
1705            })
1706        })
1707}
1708
1709/// The ambient async context a worker body should run under — captured on the
1710/// thread that *submitted* the work, applied on the thread that runs it.
1711///
1712/// **One owner for the question `spawn_dedicated_thread`'s docs above answer at
1713/// length.** Two callers need it and they differ in *when* they capture:
1714/// `spawn_dedicated_thread` captures once, at spawn, because the thread it
1715/// creates serves exactly one body; `runtime::worker_pool` captures per **job**,
1716/// because a pooled worker outlives the runtime that first used it. A pooled
1717/// worker that inherited its ambient at creation would hold a `Handle` to a
1718/// runtime that has since been dropped — every `#[tokio::test]` builds and drops
1719/// its own — and enter it for every later connection.
1720///
1721/// The `CurrentThread` filter is the rule stated above and must not be
1722/// re-derived: a current-thread ambient is *not* inherited, because
1723/// `block_on_sync` cannot distinguish "I am that runtime's thread" from "I have
1724/// merely entered its handle" and must refuse to park under it.
1725#[cfg(tokio_backend)]
1726pub(crate) struct InheritedRuntime(Option<tokio::runtime::Handle>);
1727
1728#[cfg(tokio_backend)]
1729impl InheritedRuntime {
1730    /// Capture the calling thread's runtime, if it is one a dedicated thread
1731    /// may enter.
1732    pub(crate) fn capture() -> Self {
1733        Self(
1734            tokio::runtime::Handle::try_current()
1735                .ok()
1736                .filter(|h| h.runtime_flavor() != RuntimeFlavor::CurrentThread),
1737        )
1738    }
1739
1740    /// Run `f` with the captured context entered for its whole duration.
1741    pub(crate) fn run<R>(&self, f: impl FnOnce() -> R) -> R {
1742        let _entered = self.0.as_ref().map(|h| h.enter());
1743        f()
1744    }
1745}
1746
1747/// RTEMS: the exec backend's spawn pool and timer are process-global, so there
1748/// is no per-thread context to capture or enter. Same shape so the callers need
1749/// no `cfg` of their own.
1750#[cfg(exec_backend)]
1751pub(crate) struct InheritedRuntime;
1752
1753#[cfg(exec_backend)]
1754impl InheritedRuntime {
1755    pub(crate) fn capture() -> Self {
1756        Self
1757    }
1758
1759    pub(crate) fn run<R>(&self, f: impl FnOnce() -> R) -> R {
1760        f()
1761    }
1762}
1763
1764/// RTEMS: a plain thread is already complete — the exec backend's spawn pool
1765/// and timer are process-global, so there is no per-thread context to enter.
1766#[cfg(exec_backend)]
1767pub fn spawn_dedicated_thread<F>(
1768    name: String,
1769    priority: ThreadPriority,
1770    stack: StackSizeClass,
1771    f: F,
1772) -> std::io::Result<std::thread::JoinHandle<()>>
1773where
1774    F: FnOnce() + Send + 'static,
1775{
1776    std::thread::Builder::new()
1777        .name(name)
1778        .stack_size(stack.bytes())
1779        .spawn(move || {
1780            let _ = enter_ioc_thread(priority);
1781            f()
1782        })
1783}
1784
1785#[cfg(test)]
1786mod tests {
1787    use super::*;
1788
1789    /// Everything before the first column-0 `#[cfg(test)]` — the code that
1790    /// actually ships.
1791    fn production_scope(src: &str) -> &str {
1792        match src.find("\n#[cfg(test)]") {
1793            Some(i) => &src[..i],
1794            None => src,
1795        }
1796    }
1797
1798    /// Every thread this crate creates states a stack size.
1799    ///
1800    /// `std` gives RTEMS the generic 2 MiB `DEFAULT_MIN_STACK_SIZE`
1801    /// (`std/src/sys/thread/unix.rs`: the carve-out list names vxworks, l4re,
1802    /// espidf and nuttx — not rtems). On the host that is lazily-committed
1803    /// address space and costs nothing measurable; on the target it is carved
1804    /// eagerly out of a fixed pool, which is why an unset stack size is the
1805    /// first ceiling the IOC hits rather than a rounding error.
1806    ///
1807    /// `spawn_dedicated_thread` is enforced by its signature — the class is a
1808    /// parameter, so a caller cannot omit it. This covers the threads that
1809    /// still build a `std::thread::Builder` directly.
1810    ///
1811    /// It also bans the API that has no class to state:
1812    /// `std::thread::spawn` cannot express a stack size at all, so a site
1813    /// using it does not fail the `Builder` check above — it is invisible to
1814    /// it. Same defect, different anchor. (The six bare `thread::spawn` sites
1815    /// elsewhere in the workspace — `ca::repeater`, `ca::calink`,
1816    /// `ca::server::ca_server`, `pva::server::pva_server` — all sit behind
1817    /// `#[cfg(not(target_os = "rtems"))]` module gates and are therefore
1818    /// distinct: they are not in the RTEMS closure at all. Every file listed
1819    /// here is.)
1820    ///
1821    /// Fails today, on Linux, with no cross toolchain.
1822    #[test]
1823    fn every_thread_in_this_crate_states_a_stack_size() {
1824        // The list is this crate's files only. `epics-base-rs`'s two
1825        // thread-building files (`server/ioc_app.rs`, `server/scan.rs`) are
1826        // swept by the same three assertions in that crate's own
1827        // `tests/thread_census.rs`: `include_str!` must not cross a crate
1828        // boundary — a path outside the package directory does not survive
1829        // `cargo publish` — so the guard was split by subject, not weakened.
1830        // (file label, source) — every production `Builder` site in the crate.
1831        let files = [
1832            ("runtime/task.rs", include_str!("task.rs")),
1833            (
1834                "runtime/background/delayed_timer.rs",
1835                include_str!("background/delayed_timer.rs"),
1836            ),
1837            (
1838                "runtime/background/scan_once.rs",
1839                include_str!("background/scan_once.rs"),
1840            ),
1841            (
1842                "runtime/background/callback_executor.rs",
1843                include_str!("background/callback_executor.rs"),
1844            ),
1845            ("runtime/worker_pool.rs", include_str!("worker_pool.rs")),
1846        ];
1847
1848        let mut unclassified = Vec::new();
1849        let mut checked = 0usize;
1850        for (label, src) in files {
1851            let prod = production_scope(src);
1852            for (n, after) in prod.split("thread::Builder::new()").skip(1).enumerate() {
1853                checked += 1;
1854                // The class must be set before the closure is handed over;
1855                // `.spawn(` ends the builder chain.
1856                let chain = after.split(".spawn(").next().unwrap_or("");
1857                if !chain.contains(".stack_size(") {
1858                    unclassified.push(format!("{label} (Builder #{})", n + 1));
1859                }
1860            }
1861            // The classless API. Split so this guard does not match its own
1862            // needle in the file it is written in.
1863            let bare = concat!("thread", "::spawn(");
1864            for (n, line) in prod.lines().enumerate() {
1865                let t = line.trim_start();
1866                if t.starts_with("//") {
1867                    continue;
1868                }
1869                if t.contains(bare) && !t.contains("Builder") {
1870                    unclassified.push(format!("{label}:{} (bare spawn)", n + 1));
1871                }
1872            }
1873        }
1874
1875        assert!(
1876            checked >= 7,
1877            "expected to find the crate's Builder sites, found {checked} — \
1878             did a file move? update this guard's file list"
1879        );
1880        assert!(
1881            unclassified.is_empty(),
1882            "these threads inherit std's 2 MiB default on RTEMS: {unclassified:?}"
1883        );
1884    }
1885
1886    #[epics_macros_rs::epics_test]
1887    async fn test_spawn() {
1888        let handle = spawn(async { 42 });
1889        assert_eq!(handle.await.unwrap(), 42);
1890    }
1891
1892    #[epics_macros_rs::epics_test]
1893    async fn test_spawn_blocking() {
1894        let handle = spawn_blocking(|| 123);
1895        assert_eq!(handle.await.unwrap(), 123);
1896    }
1897
1898    /// The property `spawn_dedicated_thread` exists for. A future written for
1899    /// the hosted driver — one that spawns a task and arms a timer — must run
1900    /// unchanged on the thread this hands back. On a plain `std::thread` it
1901    /// does not: it panics with "there is no reactor running" as soon as it is
1902    /// polled, however willing `block_on_sync` was to park.
1903    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1904    async fn a_dedicated_thread_carries_the_ambient_runtime() {
1905        let (tx, rx) = std::sync::mpsc::channel();
1906        let joined = spawn_dedicated_thread(
1907            "dedicated-with-runtime".into(),
1908            ThreadPriority::CaServerLow,
1909            StackSizeClass::Small,
1910            move || {
1911                let outcome = block_on_sync(async {
1912                    let inner = spawn(async { 7u32 }).await.expect("inner task");
1913                    sleep(Duration::from_millis(1)).await;
1914                    inner
1915                });
1916                let _ = tx.send((
1917                    std::thread::current().name().map(str::to_string),
1918                    outcome.ok(),
1919                ));
1920            },
1921        )
1922        .expect("dedicated thread spawned");
1923
1924        let (name, value) = rx
1925            .recv_timeout(Duration::from_secs(5))
1926            .expect("the dedicated thread must complete, not panic");
1927        assert_eq!(name.as_deref(), Some("dedicated-with-runtime"));
1928        assert_eq!(
1929            value,
1930            Some(7),
1931            "a spawn and a timer must both work on the dedicated thread"
1932        );
1933        joined.join().expect("dedicated thread joined");
1934    }
1935
1936    /// The third boundary, and the one that was missing: a **current-thread**
1937    /// ambient runtime.
1938    ///
1939    /// The two neighbours below and above cover "multi-thread ambient" and "no
1940    /// ambient". This is the case between them, and inheriting the handle there
1941    /// is what made `block_on_sync` return `NotBlockable` on a thread that was
1942    /// perfectly able to park — silently, since every caller reads the refusal
1943    /// as "the connection ended". `#[tokio::test]` is `CurrentThread` by
1944    /// default, so this is also the flavor most of the workspace's tests hand a
1945    /// dedicated thread.
1946    ///
1947    /// The assertion is on `block_on_sync` succeeding, not on the absence of a
1948    /// handle, because being able to block is the property the thread is spawned
1949    /// for; how that is arranged is this function's business.
1950    #[epics_macros_rs::epics_test]
1951    async fn a_dedicated_thread_can_block_under_a_current_thread_ambient() {
1952        let (tx, rx) = std::sync::mpsc::channel();
1953        let joined = spawn_dedicated_thread(
1954            "dedicated-current-thread-ambient".into(),
1955            ThreadPriority::Low,
1956            StackSizeClass::Small,
1957            move || {
1958                // A runtime-agnostic await, the only kind a parking thread may
1959                // use — and the exact shape both blocking-io pumps run.
1960                let (ctx, crx) = tokio::sync::mpsc::channel::<u32>(1);
1961                let outcome = block_on_sync(async move {
1962                    ctx.send(9u32).await.expect("send into a depth-1 channel");
1963                    let mut crx = crx;
1964                    crx.recv().await
1965                });
1966                let _ = tx.send(outcome.ok().flatten());
1967            },
1968        )
1969        .expect("dedicated thread spawned");
1970
1971        assert_eq!(
1972            rx.recv_timeout(Duration::from_secs(5))
1973                .expect("the dedicated thread must complete, not panic"),
1974            Some(9),
1975            "a dedicated thread must be able to park under a current-thread \
1976             ambient runtime; inheriting that handle makes block_on_sync \
1977             refuse and every pump built on it exit at once"
1978        );
1979        joined.join().expect("dedicated thread joined");
1980    }
1981
1982    /// The other boundary: no runtime to capture. The thread still runs, and a
1983    /// runtime-agnostic await still completes — that is `park_on`, and it is
1984    /// the only arm RTEMS ever takes.
1985    #[test]
1986    fn a_dedicated_thread_runs_without_an_ambient_runtime() {
1987        let (tx, rx) = std::sync::mpsc::channel();
1988        let joined = spawn_dedicated_thread(
1989            "dedicated-no-runtime".into(),
1990            ThreadPriority::Low,
1991            StackSizeClass::Small,
1992            move || {
1993                let _ = tx.send((
1994                    std::thread::current().name().map(str::to_string),
1995                    block_on_sync(async { 5u32 }).ok(),
1996                ));
1997            },
1998        )
1999        .expect("dedicated thread spawned");
2000
2001        let (name, value) = rx
2002            .recv_timeout(Duration::from_secs(5))
2003            .expect("the dedicated thread must run with no runtime to capture");
2004        assert_eq!(name.as_deref(), Some("dedicated-no-runtime"));
2005        assert_eq!(value, Some(5));
2006        joined.join().expect("dedicated thread joined");
2007    }
2008
2009    // --- The band-blocking invariant (doc/pvalink-rtems-design.md §2.3) ------
2010    //
2011    // MUST NOT: work running on a background-facility worker thread — a
2012    // callback band, the delayed timer, the scanOnce worker — block that
2013    // thread on async progress. The gate is `block_on_sync`; the mark is set
2014    // by `background::facility::run_facility_loop`, the one function every
2015    // worker loop goes through.
2016    //
2017    // Each of the three cases below is written so that a *broken* gate fails
2018    // the test instead of hanging it: the awaited future is completable from
2019    // the test thread, so a worker that parked can always be released before
2020    // the assertion runs and the pool's `Drop` can still join it.
2021
2022    /// The case the invariant exists for: a future spawned onto a callback
2023    /// band. On RTEMS this is exactly what [`spawn`] produces, and the band has
2024    /// one worker — parking it stops every deferred callback, every FLNK tail
2025    /// and every other monitor on that band.
2026    #[test]
2027    fn a_future_on_a_callback_band_is_refused_a_blocking_bridge() {
2028        use crate::runtime::background::callback_executor::CallbackPool;
2029        use crate::runtime::background::future_exec::{DEFAULT_SPAWN_PRIORITY, spawn_future};
2030
2031        let pool = CallbackPool::new();
2032        // Held by the test: `recv()` never completes until we send, so a gate
2033        // that does not refuse leaves the worker parked here.
2034        let (release, mut park_here) = tokio::sync::mpsc::channel::<()>(1);
2035        let (report, outcome) = std::sync::mpsc::channel();
2036
2037        let _handle = spawn_future(&pool.handle(), DEFAULT_SPAWN_PRIORITY, async move {
2038            let _ = report.send(block_on_sync(async move { park_here.recv().await }));
2039        });
2040
2041        let got = outcome.recv_timeout(Duration::from_secs(5));
2042        // Release a worker the gate failed to protect, so the assertions below
2043        // report a failure instead of hanging `CallbackPool::drop`'s join.
2044        let _ = release.try_send(());
2045
2046        match got {
2047            Ok(result) => assert_eq!(
2048                result.map(|v| v.is_some()),
2049                Err(NotBlockable::BackgroundWorker),
2050                "a band worker must be refused the blocking bridge, not given one"
2051            ),
2052            Err(_) => panic!(
2053                "the band worker parked inside block_on_sync instead of being \
2054                 refused — the band has one worker, so this is the deadlock the \
2055                 invariant exists to prevent"
2056            ),
2057        }
2058    }
2059
2060    /// The same thread, reached the other way: `spawn_blocking` also lands on a
2061    /// band worker under the exec backend, and a blocking closure holds that
2062    /// worker for its whole run. The rule is a property of the thread, so it
2063    /// must not depend on which spawn put the work there.
2064    #[test]
2065    fn a_blocking_closure_on_a_callback_band_is_refused_too() {
2066        use crate::runtime::background::callback_executor::CallbackPool;
2067        use crate::runtime::background::future_exec::{DEFAULT_SPAWN_PRIORITY, spawn_blocking_on};
2068
2069        let pool = CallbackPool::new();
2070        let (release, mut park_here) = tokio::sync::mpsc::channel::<()>(1);
2071        let (report, outcome) = std::sync::mpsc::channel();
2072
2073        let _handle = spawn_blocking_on(&pool.handle(), DEFAULT_SPAWN_PRIORITY, move || {
2074            let _ = report.send(block_on_sync(async move { park_here.recv().await }));
2075        });
2076
2077        let got = outcome.recv_timeout(Duration::from_secs(5));
2078        let _ = release.try_send(());
2079
2080        match got {
2081            Ok(result) => assert_eq!(
2082                result.map(|v| v.is_some()),
2083                Err(NotBlockable::BackgroundWorker),
2084                "the refusal keys on the thread, not on how work reached it"
2085            ),
2086            Err(_) => panic!("the band worker parked instead of being refused"),
2087        }
2088    }
2089
2090    /// The other side of the boundary, so the gate cannot be satisfied by
2091    /// refusing everything: an ordinary thread that merely *submits* to the
2092    /// pool still blocks. The mark covers the worker loop's own thread and
2093    /// nothing else.
2094    #[test]
2095    fn a_thread_that_only_submits_to_a_band_still_blocks() {
2096        use crate::runtime::background::callback_executor::{CallbackPool, CallbackPriority};
2097
2098        let pool = CallbackPool::new();
2099        let (tx, rx) = std::sync::mpsc::channel();
2100        pool.request(
2101            CallbackPriority::Medium,
2102            Box::new(move || tx.send(1u32).unwrap()),
2103        )
2104        .expect("the band accepts the callback");
2105        assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), 1);
2106        assert_eq!(
2107            block_on_sync(async { 5u32 }),
2108            Ok(5),
2109            "the submitting thread runs no facility loop, so it may still park"
2110        );
2111    }
2112
2113    #[epics_macros_rs::epics_test]
2114    async fn test_sleep() {
2115        let start = std::time::Instant::now();
2116        sleep(Duration::from_millis(10)).await;
2117        assert!(start.elapsed() >= Duration::from_millis(10));
2118    }
2119
2120    // The two halves of `timeout`'s contract. They read as trivial against a
2121    // tokio delegation, and that is the point: they are what a later backend
2122    // swap has to keep true, on a seam whose whole purpose is to be
2123    // reimplemented.
2124    #[epics_macros_rs::epics_test]
2125    async fn timeout_yields_the_value_when_the_future_finishes_first() {
2126        let r = timeout(Duration::from_secs(30), async { 42 }).await;
2127        assert_eq!(r.unwrap(), 42);
2128    }
2129
2130    #[epics_macros_rs::epics_test]
2131    async fn timeout_elapses_on_a_future_that_never_finishes() {
2132        let r = timeout(Duration::from_millis(10), std::future::pending::<()>()).await;
2133        assert!(r.is_err());
2134    }
2135
2136    #[test]
2137    fn priority_named_levels_match_epics_thread_h() {
2138        // epicsThread.h:73-83 named-level constants.
2139        assert_eq!(ThreadPriority::Low.value(), 10);
2140        assert_eq!(ThreadPriority::CaServerLow.value(), 20);
2141        assert_eq!(ThreadPriority::CaServerHigh.value(), 40);
2142        assert_eq!(ThreadPriority::Medium.value(), 50);
2143        assert_eq!(ThreadPriority::ScanLow.value(), 60);
2144        assert_eq!(ThreadPriority::ScanHigh.value(), 70);
2145        assert_eq!(ThreadPriority::High.value(), 90);
2146        assert_eq!(ThreadPriority::Iocsh.value(), 91);
2147    }
2148
2149    #[test]
2150    fn priority_ordering_ca_server_below_scan() {
2151        // Real-time invariant: scan threads must outrank CA-server
2152        // threads so scans preempt the CA server on a loaded IOC.
2153        assert!(ThreadPriority::CaServerHigh.value() < ThreadPriority::ScanLow.value());
2154        assert!(ThreadPriority::CaServerLow.value() < ThreadPriority::ScanLow.value());
2155    }
2156
2157    #[test]
2158    fn priority_custom_clamps_to_max() {
2159        assert_eq!(ThreadPriority::Custom(200).value(), PRIORITY_MAX);
2160        assert_eq!(ThreadPriority::Custom(99).value(), 99);
2161        assert_eq!(ThreadPriority::Custom(0).value(), PRIORITY_MIN);
2162    }
2163
2164    #[test]
2165    fn stack_size_classes_ordered() {
2166        // STACK_SIZE table is strictly increasing Small < Medium < Big.
2167        assert!(StackSizeClass::Small.bytes() < StackSizeClass::Medium.bytes());
2168        assert!(StackSizeClass::Medium.bytes() < StackSizeClass::Big.bytes());
2169        // Small = 0x10000 * sizeof(usize).
2170        assert_eq!(
2171            StackSizeClass::Small.bytes(),
2172            0x10000 * std::mem::size_of::<usize>()
2173        );
2174    }
2175
2176    /// The three classes against the C table, factor by factor.
2177    ///
2178    /// `STACK_SIZE(f) = f * 0x10000 * sizeof(void*)` with factors 1, 2, 4
2179    /// (`libcom/src/osi/os/posix/osdThread.c:506-509`) — the file a C IOC on
2180    /// RTEMS 6 compiles, because `configure/toolchain.c:29-35` picks
2181    /// `OS_API = posix` for `__RTEMS_MAJOR__ >= 5`. Pinning the factors
2182    /// separately from the unit is what makes a silent edit of one of them
2183    /// fail: `stack_size_classes_ordered` above is satisfied by any
2184    /// increasing triple.
2185    #[test]
2186    fn the_classes_are_the_c_posix_table_factor_for_factor() {
2187        let unit = 0x10000 * std::mem::size_of::<usize>();
2188        assert_eq!(StackSizeClass::Small.bytes(), unit);
2189        assert_eq!(StackSizeClass::Medium.bytes(), 2 * unit);
2190        assert_eq!(StackSizeClass::Big.bytes(), 4 * unit);
2191        // And what the same table yields on the target the RTEMS port builds
2192        // for (`sizeof(void*) == 4`), spelled out so a reader on a 64-bit
2193        // host does not have to re-derive it.
2194        const TARGET_UNIT: usize = 0x10000 * 4;
2195        assert_eq!(
2196            [TARGET_UNIT, 2 * TARGET_UNIT, 4 * TARGET_UNIT],
2197            [256 * 1024, 512 * 1024, 1024 * 1024],
2198            "armv7-rtems-eabihf: Small / Medium / Big in bytes"
2199        );
2200    }
2201
2202    /// The stack a caller *states* is the stack the thread *reports*.
2203    ///
2204    /// The source guard above only proves a number reached the builder. This
2205    /// asks the running thread what it actually got, through
2206    /// `pthread_getattr_np`, and that is the property the RTEMS ceiling
2207    /// depends on: `std` gates its 2 MiB `DEFAULT_MIN_STACK_SIZE` on a
2208    /// carve-out list that omits rtems, so a size that fails to arrive is
2209    /// silently 2 MiB rather than an error.
2210    ///
2211    /// The mechanism this exercises is not host-specific: `std`'s
2212    /// `Thread::new` calls `pthread_attr_setstacksize(attr, max(stack,
2213    /// PTHREAD_STACK_MIN))` on every non-espidf/nuttx unix
2214    /// (`std/src/sys/thread/unix.rs`), and `libc` gives rtems
2215    /// `PTHREAD_STACK_MIN = 0`, so the `max` cannot raise our request there
2216    /// either. Glibc-only because `pthread_getattr_np` is the readback API.
2217    #[cfg(all(target_os = "linux", target_env = "gnu"))]
2218    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2219    async fn a_dedicated_thread_reports_the_stack_it_was_asked_for() {
2220        fn reported_stack_bytes() -> usize {
2221            unsafe {
2222                let mut attr: libc::pthread_attr_t = std::mem::zeroed();
2223                assert_eq!(
2224                    libc::pthread_getattr_np(libc::pthread_self(), &mut attr),
2225                    0,
2226                    "pthread_getattr_np"
2227                );
2228                let mut addr: *mut libc::c_void = std::ptr::null_mut();
2229                let mut size: libc::size_t = 0;
2230                assert_eq!(
2231                    libc::pthread_attr_getstack(&attr, &mut addr, &mut size),
2232                    0,
2233                    "pthread_attr_getstack"
2234                );
2235                libc::pthread_attr_destroy(&mut attr);
2236                size
2237            }
2238        }
2239
2240        for class in [
2241            StackSizeClass::Small,
2242            StackSizeClass::Medium,
2243            StackSizeClass::Big,
2244        ] {
2245            let (tx, rx) = std::sync::mpsc::channel();
2246            let joined = spawn_dedicated_thread(
2247                format!("stack-readback-{class:?}"),
2248                ThreadPriority::CaServerLow,
2249                class,
2250                move || {
2251                    let _ = tx.send(reported_stack_bytes());
2252                },
2253            )
2254            .expect("dedicated thread spawned");
2255            let got = rx.recv().expect("the thread reported its stack");
2256            joined.join().expect("thread joined");
2257
2258            let asked = class.bytes();
2259            // The kernel rounds up to a page; it must never round *down*, and
2260            // it must not silently substitute something of a different order.
2261            assert!(
2262                got >= asked && got < asked + 64 * 1024,
2263                "{class:?}: asked for {asked} bytes, thread reports {got}"
2264            );
2265        }
2266    }
2267
2268    /// The distinguishing half of the readback: a class below `std`'s default
2269    /// must land below it. Without this the test above would pass on a
2270    /// platform that ignored every request and handed out 2 MiB, for the two
2271    /// classes that happen to be smaller than that on a 64-bit host.
2272    #[cfg(all(target_os = "linux", target_env = "gnu"))]
2273    #[test]
2274    fn the_small_classes_are_below_the_default_that_would_mask_a_failure() {
2275        const STD_DEFAULT_MIN_STACK_SIZE: usize = 2 * 1024 * 1024;
2276        assert!(StackSizeClass::Small.bytes() < STD_DEFAULT_MIN_STACK_SIZE);
2277        assert!(StackSizeClass::Medium.bytes() < STD_DEFAULT_MIN_STACK_SIZE);
2278    }
2279
2280    #[test]
2281    fn apply_priority_returns_a_defined_outcome() {
2282        // The result depends on the platform + permissions of the test
2283        // host; we only assert it is one of the defined outcomes and
2284        // does not panic. On a CI box without CAP_SYS_NICE this is
2285        // typically BestEffortFailed — which is C-parity behaviour.
2286        let outcome = apply_to_current_thread(ThreadPriority::ScanHigh);
2287        assert!(matches!(
2288            outcome,
2289            PriorityApplied::Realtime
2290                | PriorityApplied::Disabled
2291                | PriorityApplied::Unsupported
2292                | PriorityApplied::BestEffortFailed
2293        ));
2294    }
2295
2296    /// Both defaults, and both override directions against each default.
2297    ///
2298    /// The RTEMS arm is unreachable from a host test run unless the default
2299    /// is a function of the target rather than a `cfg` block, which is why
2300    /// `default_policy`/`resolve` take their input explicitly.
2301    #[test]
2302    fn the_rt_default_is_on_for_rtems_and_off_for_hosted() {
2303        // (1) the two defaults themselves
2304        assert_eq!(
2305            default_policy(true),
2306            RtPolicy::AllowRealtime,
2307            "RTEMS honours its priorities by default, as base does \
2308             (EPICS_ALLOW_POSIX_THREAD_PRIORITY_SCHEDULING=YES, CONFIG_ENV:57)"
2309        );
2310        assert_eq!(
2311            default_policy(false),
2312            RtPolicy::Disabled,
2313            "hosted stays opt-in: RLIMIT_RTPRIO makes the request fail on a \
2314             desktop, and where it succeeds a runaway band wedges the machine"
2315        );
2316
2317        // (2) the compiled-in default is wired to the target, not to a guess
2318        assert_eq!(DEFAULT_POLICY, default_policy(cfg!(target_os = "rtems")));
2319        assert_eq!(RtPolicy::from_env_value(None), DEFAULT_POLICY);
2320
2321        // (3) an explicit value wins over EITHER default, in BOTH directions.
2322        //     The RTEMS-off case is the one an operator needs: turning RT
2323        //     scheduling off on a target that defaults to on.
2324        for default in [RtPolicy::AllowRealtime, RtPolicy::Disabled] {
2325            assert_eq!(
2326                RtPolicy::resolve(Some("NO"), default),
2327                RtPolicy::Disabled,
2328                "explicit NO must turn it off even where the default is {default:?}"
2329            );
2330            assert_eq!(
2331                RtPolicy::resolve(Some("YES"), default),
2332                RtPolicy::AllowRealtime,
2333                "explicit YES must turn it on even where the default is {default:?}"
2334            );
2335            assert_eq!(
2336                RtPolicy::resolve(None, default),
2337                default,
2338                "unset must resolve to the default and nothing else"
2339            );
2340        }
2341    }
2342
2343    #[test]
2344    fn rt_switch_explicit_values_win_over_the_default() {
2345        // Unset takes the target's default; that is
2346        // `the_rt_default_is_on_for_rtems_and_off_for_hosted`'s subject.
2347        assert_eq!(RtPolicy::from_env_value(None), DEFAULT_POLICY);
2348        // C's `envGetBoolConfigParam` (envSubr.c:331) accepts only
2349        // case-insensitive "yes"; we also take the spellings a hand-written
2350        // startup script is likely to use.
2351        for on in ["YES", "yes", "Yes", "true", "TRUE", "on", "1", " yes "] {
2352            assert_eq!(
2353                RtPolicy::from_env_value(Some(on)),
2354                RtPolicy::AllowRealtime,
2355                "{on:?} should turn the switch on"
2356            );
2357        }
2358        // Everything else is off. Silence is the safe direction: a
2359        // misspelling must never grant a process RT scheduling.
2360        for off in ["", "NO", "no", "false", "off", "0", "y", "yes please", "2"] {
2361            assert_eq!(
2362                RtPolicy::from_env_value(Some(off)),
2363                RtPolicy::Disabled,
2364                "{off:?} should leave the switch off"
2365            );
2366        }
2367    }
2368
2369    /// Switch off ⟹ the OS scheduler is never called.
2370    ///
2371    /// Mutation check: deleting the `RtPolicy::Disabled` arm in
2372    /// `apply_to_current_thread_under` (so it always calls
2373    /// `apply_priority_impl`) makes the `SCHED_CALLS` assertion fail.
2374    #[cfg(target_os = "linux")]
2375    #[test]
2376    fn switch_off_makes_no_scheduler_calls() {
2377        let before = SCHED_CALLS.with(std::cell::Cell::get);
2378        for p in [
2379            ThreadPriority::Low,
2380            ThreadPriority::CaServerLow,
2381            ThreadPriority::ScanHigh,
2382            ThreadPriority::Iocsh,
2383            ThreadPriority::Custom(0),
2384            ThreadPriority::Custom(99),
2385        ] {
2386            assert_eq!(
2387                apply_to_current_thread_under(RtPolicy::Disabled, p),
2388                PriorityApplied::Disabled
2389            );
2390        }
2391        assert_eq!(
2392            SCHED_CALLS.with(std::cell::Cell::get),
2393            before,
2394            "the switch is off, so nothing may reach pthread_setschedparam"
2395        );
2396    }
2397
2398    /// Switch on: either the host grants SCHED_FIFO — and then the policy
2399    /// must actually be in force on the thread, at the mapped priority — or
2400    /// it does not, and the thread keeps running under the default policy.
2401    ///
2402    /// Runs on its own thread: on a host that *does* grant RT, leaving the
2403    /// test-harness thread in a real-time band would outlive the test.
2404    #[cfg(target_os = "linux")]
2405    #[test]
2406    fn switch_on_either_sticks_or_falls_back_without_killing_the_thread() {
2407        let outcome = std::thread::spawn(|| {
2408            // Resolve (and cache) the probe first, so what the call below is
2409            // expected to do is known rather than order-dependent.
2410            let range = permitted_fifo_range();
2411            let before = SCHED_CALLS.with(std::cell::Cell::get);
2412            let outcome =
2413                apply_to_current_thread_under(RtPolicy::AllowRealtime, ThreadPriority::ScanHigh);
2414            let calls = SCHED_CALLS.with(std::cell::Cell::get) - before;
2415
2416            // Whatever the host allowed, this thread is still running.
2417            assert_eq!(2 + 2, 4);
2418
2419            let mut policy = 0i32;
2420            let mut param = libc::sched_param { sched_priority: 0 };
2421            // SAFETY: reads the calling thread's own scheduling into
2422            // stack-local outputs.
2423            let rc = unsafe {
2424                libc::pthread_getschedparam(libc::pthread_self(), &mut policy, &mut param)
2425            };
2426            assert_eq!(rc, 0, "pthread_getschedparam failed");
2427
2428            match (range, outcome) {
2429                (RtRange::Available { min, max }, PriorityApplied::Realtime) => {
2430                    // The host permits FIFO — assert the policy stuck, at
2431                    // the C-mapped priority, off exactly one scheduler call.
2432                    assert_eq!(calls, 1, "one apply must be one scheduler call");
2433                    assert_eq!(policy, libc::SCHED_FIFO, "SCHED_FIFO did not stick");
2434                    assert_eq!(
2435                        param.sched_priority,
2436                        map_epics_priority(ThreadPriority::ScanHigh.value(), min, max),
2437                        "wrong OS priority for epicsThreadPriorityScanHigh"
2438                    );
2439                }
2440                (RtRange::Denied, PriorityApplied::BestEffortFailed) => {
2441                    // Unprivileged: the fallback leaves the thread at the
2442                    // default policy rather than failing the caller, and —
2443                    // the anti-spam property — asks the OS nothing further
2444                    // now that the probe has settled the question once.
2445                    assert_eq!(calls, 0, "a settled denial must not re-ask the OS");
2446                    assert_ne!(
2447                        policy,
2448                        libc::SCHED_FIFO,
2449                        "fallback reported but the thread is real-time scheduled"
2450                    );
2451                }
2452                (RtRange::Unsupported, PriorityApplied::Unsupported) => {
2453                    assert_eq!(calls, 0, "no SCHED_FIFO range means no scheduler call");
2454                }
2455                (range, outcome) => {
2456                    panic!("range {range:?} and outcome {outcome:?} disagree")
2457                }
2458            }
2459            outcome
2460        })
2461        .join()
2462        .expect("probe thread panicked");
2463        eprintln!("host RT outcome: {outcome:?}");
2464    }
2465
2466    /// The unprivileged fallback is logged once, not once per thread.
2467    #[cfg(target_os = "linux")]
2468    #[test]
2469    fn denial_is_reported_once_not_per_thread() {
2470        let threads: Vec<_> = (0..8)
2471            .map(|_| {
2472                std::thread::spawn(|| {
2473                    for _ in 0..8 {
2474                        let _ = apply_to_current_thread_under(
2475                            RtPolicy::AllowRealtime,
2476                            ThreadPriority::Low,
2477                        );
2478                    }
2479                })
2480            })
2481            .collect();
2482        for t in threads {
2483            t.join().expect("worker panicked");
2484        }
2485        assert!(
2486            DENIED_WARNINGS.load(std::sync::atomic::Ordering::Relaxed) <= 1,
2487            "64 denied requests across 8 threads must not produce more than one warning"
2488        );
2489    }
2490
2491    /// The mapping itself, against `epicsThreadGetPosixPriority`
2492    /// (`osdThread.c:129-144`).
2493    #[cfg(target_os = "linux")]
2494    #[test]
2495    fn epics_priority_maps_onto_the_permitted_fifo_range() {
2496        // Linux's nominal SCHED_FIFO range.
2497        let (min, max) = (1, 99);
2498        // oss = p * (max-min)/100 + min
2499        assert_eq!(map_epics_priority(0, min, max), 1);
2500        assert_eq!(map_epics_priority(20, min, max), 1 + (20.0 * 0.98) as i32);
2501        assert_eq!(map_epics_priority(99, min, max), 1 + (99.0 * 0.98) as i32);
2502        // Ordering is preserved: the CA server sits below the scan bands.
2503        assert!(
2504            map_epics_priority(ThreadPriority::CaServerHigh.value(), min, max)
2505                < map_epics_priority(ThreadPriority::ScanLow.value(), min, max)
2506        );
2507        // A range restricted by RLIMIT_RTPRIO still spans the whole EPICS
2508        // space rather than saturating at the top.
2509        assert_eq!(map_epics_priority(0, 1, 10), 1);
2510        assert_eq!(map_epics_priority(99, 1, 10), 1 + (99.0 * 0.09) as i32);
2511        // Degenerate range collapses (osdThread.c:133).
2512        assert_eq!(map_epics_priority(50, 7, 7), 7);
2513    }
2514
2515    /// The RTEMS map's *image* is the whole point of choosing it, so assert
2516    /// the image, not sampled points: for **every** `u8` input the resulting
2517    /// RTEMS core priority lands in `100..=199`, and therefore below libbsd's
2518    /// network threads.
2519    ///
2520    /// Provenance of the band, measured on the bring-up guest (RTEMS 6 +
2521    /// libbsd, QEMU `xilinx_zynq_a9`) — lower core number is *more* urgent:
2522    ///
2523    /// | core | thread |
2524    /// |------|--------|
2525    /// | 96   | libbsd `IRQS` (interrupt server) |
2526    /// | 98   | libbsd `TIME` |
2527    /// | 100  | libbsd default — twelve further network threads |
2528    /// | 254  | DHCP, outside the band |
2529    /// | 255  | idle, `RTEMS_MAXIMUM_PRIORITY` |
2530    ///
2531    /// So `core >= 100` means: never more urgent than any libbsd network
2532    /// thread, and strictly less urgent than `IRQS`/`TIME`. That is a
2533    /// property of the map's construction (fixed offsets over a 100-wide
2534    /// EPICS space), not of the endpoints, which is why no input — including
2535    /// the out-of-range ones `ThreadPriority::value` cannot currently produce
2536    /// — can escape it.
2537    #[test]
2538    fn rtems_priority_map_stays_below_the_libbsd_network_band() {
2539        /// libbsd's most urgent network thread on the measured guest.
2540        const LIBBSD_IRQS_CORE: i32 = 96;
2541        /// libbsd's default band; twelve of its threads sit here.
2542        const LIBBSD_DEFAULT_CORE: i32 = 100;
2543        // The guest's settable SCHED_FIFO range.
2544        const POSIX_MIN: i32 = 1;
2545        const POSIX_MAX: i32 = 254;
2546
2547        for epics in 0..=u8::MAX {
2548            let posix = map_epics_priority_rtems(epics);
2549            let core = RTEMS_MAXIMUM_PRIORITY - posix;
2550            assert!(
2551                (POSIX_MIN..=POSIX_MAX).contains(&posix),
2552                "EPICS {epics} maps to posix {posix}, outside the settable \
2553                 [{POSIX_MIN}, {POSIX_MAX}]"
2554            );
2555            assert!(
2556                core >= LIBBSD_DEFAULT_CORE,
2557                "EPICS {epics} maps to core {core}, more urgent than libbsd's \
2558                 default band ({LIBBSD_DEFAULT_CORE}) and its IRQS \
2559                 ({LIBBSD_IRQS_CORE})"
2560            );
2561            assert!(
2562                core <= RTEMS_MAXIMUM_PRIORITY - 56,
2563                "EPICS {epics} maps to core {core}, less urgent than the \
2564                 EPICS band's own floor of 199"
2565            );
2566        }
2567        // The two ends of the EPICS space, as `RTEMS-score/osdThread.c:94-102`
2568        // defines them, and the clamp above it.
2569        assert_eq!(map_epics_priority_rtems(0), 56);
2570        assert_eq!(map_epics_priority_rtems(99), 155);
2571        assert_eq!(map_epics_priority_rtems(100), 155);
2572        assert_eq!(map_epics_priority_rtems(u8::MAX), 155);
2573        // Ordering still holds: a higher EPICS priority is a more urgent core.
2574        assert!(
2575            RTEMS_MAXIMUM_PRIORITY - map_epics_priority_rtems(ThreadPriority::ScanLow.value())
2576                < RTEMS_MAXIMUM_PRIORITY
2577                    - map_epics_priority_rtems(ThreadPriority::CaServerHigh.value())
2578        );
2579    }
2580
2581    /// The RTEMS map is not the hosted map with retuned endpoints, and the
2582    /// difference is exactly the reason the RTEMS arm exists.
2583    ///
2584    /// Stated as the hazard rather than as `assert_ne!` on a sample: over the
2585    /// EPICS space, feeding the *hosted linear* map the guest's own probed
2586    /// range (`min=1`, `max=254`) puts some priorities above libbsd's `IRQS`
2587    /// at core 96, and the fixed RTEMS map puts none there. The crossover is
2588    /// pinned at EPICS 63 because that number is the justification recorded in
2589    /// the commit message; if base's map or the measured band ever moves, this
2590    /// fails rather than the deviation quietly losing its reason.
2591    #[test]
2592    fn rtems_priority_map_is_not_the_hosted_linear_map() {
2593        const LIBBSD_IRQS_CORE: i32 = 96;
2594        // What `find_pri_range` yields on the guest (osdThread.c:295-311).
2595        let (min, max) = (1, 254);
2596
2597        let hosted_core = |epics: u8| RTEMS_MAXIMUM_PRIORITY - map_epics_priority(epics, min, max);
2598        let rtems_core = |epics: u8| RTEMS_MAXIMUM_PRIORITY - map_epics_priority_rtems(epics);
2599
2600        let hosted_above_irqs: Vec<u8> = (0..=99)
2601            .filter(|&e| hosted_core(e) < LIBBSD_IRQS_CORE)
2602            .collect();
2603        let rtems_above_irqs: Vec<u8> = (0..=99)
2604            .filter(|&e| rtems_core(e) < LIBBSD_IRQS_CORE)
2605            .collect();
2606
2607        assert_eq!(
2608            rtems_above_irqs,
2609            Vec::<u8>::new(),
2610            "the RTEMS map must place no EPICS priority above libbsd's IRQS"
2611        );
2612        assert_eq!(
2613            hosted_above_irqs.first().copied(),
2614            Some(63),
2615            "base-on-RTEMS-6's posix map crosses IRQS at EPICS 63; that number \
2616             is the recorded reason for this deviation"
2617        );
2618        // And concretely at the CA server band the audit cares about.
2619        assert_eq!(
2620            hosted_core(91),
2621            24,
2622            "upstream posix map: EPICS 91 -> core 24"
2623        );
2624        assert_eq!(rtems_core(91), 108, "this port: EPICS 91 -> core 108");
2625        // Shapes, not endpoints: the hosted map spans the whole probed range,
2626        // this one spans exactly 100 levels wherever it is placed.
2627        assert_eq!(
2628            map_epics_priority_rtems(99) - map_epics_priority_rtems(0),
2629            99
2630        );
2631        assert_eq!(
2632            map_epics_priority(99, min, max) - map_epics_priority(0, min, max),
2633            250
2634        );
2635    }
2636
2637    /// The name budget an RTEMS thread object actually has:
2638    /// `CONFIGURE_MAXIMUM_THREAD_NAME_SIZE` defaults to 16 *including* the
2639    /// NUL (`rtems/score/thread.h:1079`, `rtems/confdefs/threads.h:92-93`)
2640    /// and the boot shim does not override it, so 15 bytes — the same budget
2641    /// `std` truncates to on Linux.
2642    ///
2643    /// Truncating here rather than letting `_Thread_Set_name` do it is what
2644    /// keeps the call's result meaningful: that function `strlcpy`s and
2645    /// *still applies* the truncated name, but returns
2646    /// `STATUS_RESULT_TOO_LARGE` → `ERANGE`, so an untruncated call would log
2647    /// a failure for a name it had in fact set.
2648    #[test]
2649    fn thread_names_are_cut_to_the_rtems_budget_on_a_char_boundary() {
2650        assert_eq!(RTEMS_MAX_THREAD_NAME_BYTES, 15);
2651        // Short names pass through untouched.
2652        assert_eq!(truncate_thread_name("CAS-event"), "CAS-event");
2653        // Exactly at the budget.
2654        assert_eq!(truncate_thread_name("123456789012345"), "123456789012345");
2655        // Over it — a real per-client CA thread name.
2656        assert_eq!(
2657            truncate_thread_name("CAS-client-blocking 10.0.0.1:5064"),
2658            "CAS-client-blo"[..14].to_owned() + "c"
2659        );
2660        assert!(truncate_thread_name("CAS-client-blocking 10.0.0.1:5064").len() <= 15);
2661        // Never mid-codepoint: 'é' is two bytes, so a cut landing inside it
2662        // must step back rather than produce invalid UTF-8. 14 ASCII bytes
2663        // plus 'é' is 16 bytes; the budget cuts at 15, inside the 'é'.
2664        let mixed = "aaaaaaaaaaaaaaé";
2665        assert_eq!(mixed.len(), 16);
2666        assert_eq!(truncate_thread_name(mixed), "aaaaaaaaaaaaaa");
2667        // Empty stays empty rather than underflowing the boundary walk.
2668        assert_eq!(truncate_thread_name(""), "");
2669    }
2670
2671    /// Every thread this crate starts publishes its name to the OS.
2672    ///
2673    /// `std` calls the platform `pthread_setname_np` from `Builder::spawn`
2674    /// on the hosted targets it supports, and RTEMS is not one of them — so
2675    /// there, a name set with `Builder::name` lives only in Rust's `Thread`
2676    /// struct and the kernel shows nothing. Bring-up had to measure libbsd's
2677    /// priority band by other means for exactly that reason.
2678    ///
2679    /// The defect is a call that is *absent*, so this is source inspection
2680    /// over every production `Builder` site in the crate — the same sweep
2681    /// shape as `every_thread_in_this_crate_states_a_stack_size`, and it
2682    /// fails the same way when a new thread forgets. Either prologue counts:
2683    /// `enter_ioc_thread` for a thread with an EPICS band, bare
2684    /// `name_current_thread` for one that deliberately has none.
2685    #[test]
2686    fn every_thread_in_this_crate_publishes_its_name() {
2687        // This crate's files only — see the note on the sweep above.
2688        let files = [
2689            ("runtime/task.rs", include_str!("task.rs")),
2690            (
2691                "runtime/background/delayed_timer.rs",
2692                include_str!("background/delayed_timer.rs"),
2693            ),
2694            (
2695                "runtime/background/scan_once.rs",
2696                include_str!("background/scan_once.rs"),
2697            ),
2698            (
2699                "runtime/background/callback_executor.rs",
2700                include_str!("background/callback_executor.rs"),
2701            ),
2702        ];
2703        // The one exemption, named rather than pattern-matched: the
2704        // SCHED_FIFO range probe is `#[cfg(target_os = "linux")]`, exists for
2705        // two `sched_*` calls and a join, and never runs on the target whose
2706        // task listing this guard is about.
2707        const EXEMPT: &str = ".name(\"cbRtProbe\".to_string())";
2708
2709        let mut anonymous = Vec::new();
2710        let mut checked = 0usize;
2711        for (label, src) in files {
2712            for (n, after) in production_scope(src)
2713                .split("thread::Builder::new()")
2714                .skip(1)
2715                .enumerate()
2716            {
2717                let (chain, body) = after.split_once(".spawn(").unwrap_or((after, ""));
2718                if chain.contains(EXEMPT) {
2719                    continue;
2720                }
2721                checked += 1;
2722                // The prologue is the closure's first work, so look at the
2723                // closure, not the builder chain.
2724                if !body.contains("enter_ioc_thread(") && !body.contains("name_current_thread()") {
2725                    anonymous.push(format!("{label} (Builder #{})", n + 1));
2726                }
2727            }
2728        }
2729
2730        assert!(
2731            checked >= 5,
2732            "expected to find the crate's Builder sites, found {checked} — \
2733             did a file move? update this guard's file list"
2734        );
2735        assert!(
2736            anonymous.is_empty(),
2737            "these threads are invisible in an RTEMS task listing: {anonymous:?}"
2738        );
2739    }
2740
2741    /// The banding half of the prologue is not reachable without the naming
2742    /// half: nothing in this crate's production scope calls
2743    /// [`apply_to_current_thread`] except [`enter_ioc_thread`] itself.
2744    ///
2745    /// Separate from the sweep above because it catches the other direction —
2746    /// a thread that is named by `Builder` but takes its band directly, which
2747    /// the closure-body sweep would pass if the naming call happened to be
2748    /// somewhere else in the file.
2749    #[test]
2750    fn only_the_prologue_reaches_the_banding_call() {
2751        // This crate's files only — see the note on the sweep above.
2752        let files = [
2753            ("runtime/task.rs", include_str!("task.rs")),
2754            (
2755                "runtime/background/delayed_timer.rs",
2756                include_str!("background/delayed_timer.rs"),
2757            ),
2758            (
2759                "runtime/background/scan_once.rs",
2760                include_str!("background/scan_once.rs"),
2761            ),
2762            (
2763                "runtime/background/callback_executor.rs",
2764                include_str!("background/callback_executor.rs"),
2765            ),
2766        ];
2767        // Only the definition and the prologue's own delegation, both in
2768        // task.rs. Anywhere else is a thread banded without being named.
2769        let allowed = [
2770            "pub fn apply_to_current_thread(priority: ThreadPriority) -> PriorityApplied {",
2771            "apply_to_current_thread(priority)",
2772        ];
2773        let mut seen_definition = false;
2774        for (label, src) in files {
2775            let callers: Vec<&str> = production_scope(src)
2776                .lines()
2777                .map(str::trim)
2778                .filter(|l| l.contains("apply_to_current_thread("))
2779                .filter(|l| !l.starts_with("//"))
2780                .collect();
2781            seen_definition |= callers.contains(&allowed[0]);
2782            let strays: Vec<&&str> = callers.iter().filter(|l| !allowed.contains(l)).collect();
2783            assert!(
2784                strays.is_empty(),
2785                "{label}: only `enter_ioc_thread` may band a thread; \
2786                 everything else would band an OS-anonymous one — {strays:?}"
2787            );
2788        }
2789        assert!(
2790            seen_definition,
2791            "the banding function moved out of this file list; update the guard"
2792        );
2793    }
2794
2795    #[epics_macros_rs::epics_test]
2796    async fn spawn_blocking_with_priority_runs_closure() {
2797        let handle = spawn_blocking_with_priority(ThreadPriority::CaServerHigh, || 7);
2798        assert_eq!(handle.await.unwrap(), 7);
2799    }
2800
2801    #[test]
2802    fn background_global_inits_and_runs_work() {
2803        // Host-exercises the OnceLock init path the RTEMS spawn/sleep/interval
2804        // arms rely on: background_init() forces creation, background() hands
2805        // back a usable executor whose callback pool runs submitted work.
2806        background_init();
2807        let exec = background();
2808        let (tx, rx) = std::sync::mpsc::channel();
2809        exec.callbacks()
2810            .handle()
2811            .request(
2812                crate::runtime::background::CallbackPriority::Medium,
2813                Box::new(move || tx.send(1u8).unwrap()),
2814            )
2815            .unwrap();
2816        assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), 1);
2817    }
2818}