Skip to main content

epics_libcom_rs/runtime/background/
callback_executor.rs

1//! General-purpose callback executor pool — RTEMS-safe port of
2//! `modules/database/src/ioc/db/callback.c`.
3//!
4//! # C parity
5//!
6//! C `callback.c` runs `NUM_CALLBACK_PRIORITIES == 3` (`callback.h:40`)
7//! independent priority bands — `priorityLow`/`priorityMedium`/`priorityHigh`
8//! = 0/1/2 (`callback.h:41-43`) — each with its own bounded ring buffer, its
9//! own wake-up event, and `callbackThreadsDefault == 1` worker thread(s)
10//! (`callback.c:66`, sized by `threadsConfigured`). `callbackRequest`
11//! (`callback.c:341`) pushes an `epicsCallback` onto the band's ring and
12//! signals the band's event; `callbackTask` (`callback.c:210`) waits on the
13//! event, drains the ring, and invokes each callback.
14//!
15//! This module keeps that structure but with **plain `std` threads +
16//! `Mutex`/`Condvar`** and boxed closures instead of C function pointers, so
17//! it carries **no tokio-runtime dependency** and runs on RTEMS
18//! (armv7-rtems-eabihf). The OS thread priority per band is applied
19//! best-effort via the existing [`apply_to_current_thread`](crate::runtime::task::apply_to_current_thread) abstraction in
20//! [`crate::runtime::task`] — this module does **not** duplicate that logic.
21//!
22//! ## Overflow hysteresis (`callback.c:365-374`, `:227`)
23//!
24//! C sets a per-band `queueOverflow` flag when a push finds the ring full; a
25//! subsequent `callbackRequest` returns `S_db_bufFull` *immediately*
26//! (`callback.c:365`) without even attempting a push, until a worker pops an
27//! entry and clears the flag (`callback.c:227`). We reproduce that exact
28//! latch: once `overflow` is set, `request` rejects until a worker drains one
29//! entry.
30
31use std::collections::VecDeque;
32use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering};
33use std::sync::{Arc, Condvar, LazyLock, Mutex};
34use std::thread::JoinHandle;
35
36use super::facility::{recover, run_facility_loop, run_isolated};
37use crate::runtime::task::{MandatoryThread, StackSizeClass, ThreadPriority};
38
39/// A unit of deferred work. The C `epicsCallback` is a function pointer plus
40/// user data; the Rust port boxes a `FnOnce` closure that already captures its
41/// context.
42pub type Callback = Box<dyn FnOnce() + Send + 'static>;
43
44/// Number of callback priority bands — C `NUM_CALLBACK_PRIORITIES`
45/// (`callback.h:40`).
46pub const NUM_CALLBACK_PRIORITIES: usize = 3;
47
48/// Default per-band ring capacity — C `callbackQueueSize` (`callback.c:51`).
49pub const DEFAULT_QUEUE_SIZE: usize = 2000;
50
51/// Default worker threads per band — C `callbackThreadsDefault`
52/// (`callback.c:66`).
53pub const DEFAULT_THREADS_PER_PRIORITY: usize = 1;
54
55/// The sizing [`CallbackPool::new`] will use — C's `callbackQueueSize`
56/// and `callbackQueue[i].threadsConfigured` file-statics
57/// (`callback.c:51`, `:60`), which `callbackSetQueueSize` and
58/// `callbackParallelThreads` write before `callbackInit` reads them.
59///
60/// They are module state for the same reason C's are: the pool is built
61/// once, from a `OnceLock` initialiser that takes no arguments, and the
62/// iocsh commands that size it run long before anything touches it.
63/// Writing one after the pool exists changes nothing, which is why both
64/// commands refuse once the pool is up.
65static CONFIGURED_QUEUE_SIZE: AtomicUsize = AtomicUsize::new(DEFAULT_QUEUE_SIZE);
66static CONFIGURED_THREADS: [AtomicUsize; NUM_CALLBACK_PRIORITIES] = [
67    AtomicUsize::new(DEFAULT_THREADS_PER_PRIORITY),
68    AtomicUsize::new(DEFAULT_THREADS_PER_PRIORITY),
69    AtomicUsize::new(DEFAULT_THREADS_PER_PRIORITY),
70];
71
72/// C `callbackSetQueueSize` (`callback.c:101-113`) minus its two
73/// diagnostics: the caller owns those, because C prints them from the
74/// same function only because C has nowhere else to put them.
75///
76/// A size of zero or less is the caller's error to report; this clamps
77/// to at least 1 so the pool can never be built with an unusable ring.
78pub fn set_queue_size(size: usize) {
79    CONFIGURED_QUEUE_SIZE.store(size.max(1), Ordering::Relaxed);
80}
81
82/// C `callbackParallelThreads(count, prio)` (`callback.c:160-208`) for
83/// one band, or for all three when `priority` is `None` — C's
84/// `NULL`/`""`/`"*"` case. `count` is clamped to at least 1 exactly as
85/// `callback.c:171` does.
86pub fn set_parallel_threads(count: usize, priority: Option<CallbackPriority>) {
87    let count = count.max(1);
88    match priority {
89        Some(p) => CONFIGURED_THREADS[p.index()].store(count, Ordering::Relaxed),
90        None => {
91            for slot in &CONFIGURED_THREADS {
92                slot.store(count, Ordering::Relaxed);
93            }
94        }
95    }
96}
97
98/// C `epicsThreadGetCPUs()` (`osdThread.c`), the live processor count.
99///
100/// **Post-pin forward-port: this tracks epics-base HEAD, not R7.0.10.** At
101/// the pin (`osdThread.c:1123-1137`) the function is `sysconf` of
102/// `_SC_NPROCESSORS_ONLN`, then `_SC_NPROCESSORS_CONF`, then a hardcoded 1
103/// — none of which consults the calling thread's CPU affinity mask, so a C
104/// IOC pinned to 2 of 64 processors still sizes its callback pool for 64.
105/// `556de06ff` ("avoid overreporting available CPUs", 2026-02-06, branch
106/// 7.0, in no tag) puts a `sched_getaffinity` + `CPU_COUNT` arm ahead of
107/// both `sysconf` calls. `std::thread::available_parallelism` is that
108/// behaviour, so this has been carrying the fix rather than the pin.
109/// Deliberately kept — reverting onto a number upstream itself calls
110/// overreporting buys no parity worth having — and named here because
111/// until now it was silent.
112///
113/// One divergence beyond that forward-port, stated rather than folded into
114/// it: `available_parallelism` also clamps to the cgroup CPU quota, which
115/// `556de06ff` does not. In a container limited to 2 CPUs with no affinity
116/// mask set this returns 2 where even post-`556de06ff` C returns the host
117/// count. Same direction as the upstream fix, so it stays.
118///
119/// Distinct from [`parallel_threads_default`], which is a settable knob
120/// merely SEEDED from this. `callbackParallelThreads` reads the two on
121/// different arms — a negative count is relative to the processor count,
122/// a zero count means the knob (`callback.c:167-170`) — so collapsing
123/// them into one accessor makes `var callbackParallelThreadsDefault N`
124/// silently move the negative arm too.
125pub fn cpu_count() -> i32 {
126    std::thread::available_parallelism()
127        .map(|n| n.get())
128        .unwrap_or(1) as i32
129}
130
131/// C `callbackParallelThreadsDefault` (`callback.c:69`) — the value
132/// `callbackParallelThreads(0, ...)` resolves to (`callback.c:170`).
133///
134/// C declares it `2` and then overwrites it with `epicsThreadGetCPUs()`
135/// in `dbIocRegister` (`dbIocRegister.c:638-639`), commented there
136/// "Needed before callback system is initialized". That assignment runs
137/// during registration, so `2` is never a value an IOC can observe and
138/// this seeds the processor count directly rather than reproducing a
139/// registration phase that has no counterpart here.
140///
141/// It is an `iocshVar` (`dbCore.dbd:32`, `variable(...,int)`), so a
142/// startup script may write it, and C reads it at the point of use, not
143/// at init. `i32` because C's is an `int`: a negative value is writable
144/// and reaches `callback.c:171`'s floor, which `usize` could not carry.
145static PARALLEL_THREADS_DEFAULT: LazyLock<AtomicI32> =
146    LazyLock::new(|| AtomicI32::new(cpu_count()));
147
148/// Read C `callbackParallelThreadsDefault`.
149pub fn parallel_threads_default() -> i32 {
150    PARALLEL_THREADS_DEFAULT.load(Ordering::Relaxed)
151}
152
153/// Write C `callbackParallelThreadsDefault` — the `var` command's setter.
154pub fn set_parallel_threads_default(value: i32) {
155    PARALLEL_THREADS_DEFAULT.store(value, Ordering::Relaxed);
156}
157
158/// Callback priority band — C `priorityLow`/`priorityMedium`/`priorityHigh`
159/// (`callback.h:41-43`).
160#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
161pub enum CallbackPriority {
162    /// `priorityLow` (0).
163    Low,
164    /// `priorityMedium` (1).
165    Medium,
166    /// `priorityHigh` (2).
167    High,
168}
169
170impl CallbackPriority {
171    /// All bands in index order — mirrors the `for (i = 0; i <
172    /// NUM_CALLBACK_PRIORITIES; i++)` loops in `callback.c`.
173    pub const ALL: [CallbackPriority; NUM_CALLBACK_PRIORITIES] = [
174        CallbackPriority::Low,
175        CallbackPriority::Medium,
176        CallbackPriority::High,
177    ];
178
179    /// The `0..3` band index — C `priorityValue` (`callback.c:98`).
180    pub fn index(self) -> usize {
181        match self {
182            CallbackPriority::Low => 0,
183            CallbackPriority::Medium => 1,
184            CallbackPriority::High => 2,
185        }
186    }
187
188    /// The band a record's `PRIO` field selects — C `callbackSetPriority`
189    /// (`callback.h:91-92`), which copies the record's `menuPriority` index
190    /// (`menuPriority.dbd.pod:24-28` — `LOW`/`MEDIUM`/`HIGH` = 0/1/2, the same
191    /// three values as `priorityLow`/`priorityMedium`/`priorityHigh`,
192    /// `callback.h:41-43`) straight into `CALLBACK.priority`.
193    ///
194    /// C validates the copied value only when the callback is queued:
195    /// `callbackRequest` drops it with "Bad priority" (`callback.c:355-357`)
196    /// when it is outside `0..NUM_CALLBACK_PRIORITIES`. Dropping a record's
197    /// deferred work loses that cycle outright, so an out-of-range `PRIO`
198    /// lands on `Low` here instead — the band a record whose `PRIO` was never
199    /// written already has.
200    pub fn from_record_prio(prio: i16) -> CallbackPriority {
201        match prio {
202            1 => CallbackPriority::Medium,
203            2 => CallbackPriority::High,
204            _ => CallbackPriority::Low,
205        }
206    }
207
208    /// Worker-thread name prefix — C `threadNamePrefix` (`callback.c:86-88`).
209    pub fn name_prefix(self) -> &'static str {
210        match self {
211            CallbackPriority::Low => "cbLow",
212            CallbackPriority::Medium => "cbMedium",
213            CallbackPriority::High => "cbHigh",
214        }
215    }
216
217    /// OS thread priority for this band — C `threadPriority`
218    /// (`callback.c:93-97`): `epicsThreadPriorityScanLow - 1`,
219    /// `epicsThreadPriorityScanLow + 4`, `epicsThreadPriorityScanHigh + 1`.
220    /// Values are derived from [`ThreadPriority`] so the parity link to
221    /// `epicsThread.h` stays in one place.
222    pub fn os_priority(self) -> ThreadPriority {
223        let scan_low = ThreadPriority::ScanLow.value(); // 60 (epicsThread.h:84)
224        let scan_high = ThreadPriority::ScanHigh.value(); // 70 (epicsThread.h:85)
225        match self {
226            CallbackPriority::Low => ThreadPriority::Custom(scan_low - 1),
227            CallbackPriority::Medium => ThreadPriority::Custom(scan_low + 4),
228            CallbackPriority::High => ThreadPriority::Custom(scan_high + 1),
229        }
230    }
231}
232
233/// Why a [`CallbackHandle::request`] was rejected.
234///
235/// Note there is no `Shutdown` variant: a request arriving after the pool has
236/// stopped is a silent no-op returning `Ok(())`, matching C — `callbackStop`
237/// halts the queues and late `callbackRequest`s are simply dropped without
238/// surfacing an error to the caller (`callback.c:237-284`).
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum CallbackError {
241    /// The band's ring was full — C `S_db_bufFull` (`callback.c:373`). Either
242    /// the push found the ring at capacity, or the overflow latch is still set
243    /// from a prior full push (`callback.c:365`).
244    QueueFull,
245}
246
247/// One FIFO entry of a band.
248enum Queued {
249    /// A `callbackRequest` — holds one of the ring's `capacity` slots.
250    Ring(Callback),
251    /// A spawned future's run-queue entry. A task has at most one at a time
252    /// (its `SCHEDULED` state is the claim), so these are bounded by the live
253    /// task count and take no ring slot: a wake that the ring could reject
254    /// would strand a long-lived task forever.
255    Task(Callback),
256}
257
258/// Mutable, lock-guarded state of one priority band's ring.
259struct QueueState {
260    queue: VecDeque<Queued>,
261    /// Ring slots in use — the `Queued::Ring` entries in `queue`. This, not
262    /// `queue.len()`, is what C's bounded ring measures: a task's run-queue
263    /// entry shares the FIFO but holds no ring slot.
264    ring_used: usize,
265    /// C `epicsRingPointerGetHighWaterMark` on the band's ring — the
266    /// deepest the queue has ever been. `callbackQueueShow` reports it
267    /// and `callbackQueueStatus(reset=1)` clears it
268    /// (`callback.c:115-139`), so it is not derivable from `queue.len()`
269    /// after the fact and has to be latched on every push.
270    high_water: usize,
271    /// C `cbQueueSet.queueOverflow` — latched full flag (`callback.c:56`).
272    overflow: bool,
273    /// C `cbQueueSet.queueOverflows` — lifetime overflow count
274    /// (`callback.c:57`).
275    overflows: u64,
276    shutdown: bool,
277}
278
279/// One priority band: a bounded ring plus its wake-up condvar. Mirrors C
280/// `cbQueueSet` (`callback.c:53-62`).
281struct PriorityQueue {
282    capacity: usize,
283    state: Mutex<QueueState>,
284    /// C `cbQueueSet.semWakeUp` (`callback.c:54`).
285    wake: Condvar,
286}
287
288impl PriorityQueue {
289    fn new(capacity: usize) -> Self {
290        PriorityQueue {
291            capacity,
292            state: Mutex::new(QueueState {
293                queue: VecDeque::with_capacity(capacity.min(1024)),
294                ring_used: 0,
295                high_water: 0,
296                overflow: false,
297                overflows: 0,
298                shutdown: false,
299            }),
300            wake: Condvar::new(),
301        }
302    }
303
304    /// Port of `callbackRequest` for a single band (`callback.c:341-377`).
305    fn request(&self, name: &str, cb: Callback) -> Result<(), CallbackError> {
306        let mut st = recover(FACILITY, self.state.lock());
307        if st.shutdown {
308            // Pool stopped: C drops late callbackRequests after callbackStop
309            // without surfacing an error (`callback.c:237-284`). Drop `cb`
310            // (deallocated here, never invoked) and report success. This also
311            // absorbs the teardown race where the delayed timer fires into a
312            // pool that has just been dropped.
313            drop(st);
314            tracing::trace!(
315                target: "epics_base_rs::runtime::callback",
316                band = name,
317                "callbackRequest after shutdown dropped"
318            );
319            return Ok(());
320        }
321        // callback.c:365 — reject immediately while the overflow latch is set.
322        if st.overflow {
323            return Err(CallbackError::QueueFull);
324        }
325        // callback.c:367-374 — push; on a full ring, latch overflow and count.
326        if st.ring_used >= self.capacity {
327            st.overflow = true;
328            st.overflows += 1;
329            // callback.c:370 — `fullMessage[priority]`, printed once per
330            // overflow episode (the latch above suppresses repeats).
331            tracing::error!(
332                target: "epics_base_rs::runtime::callback",
333                band = name,
334                "callbackRequest: ERROR {} ring buffer full",
335                name
336            );
337            return Err(CallbackError::QueueFull);
338        }
339        st.queue.push_back(Queued::Ring(cb));
340        st.ring_used += 1;
341        // The ring's high-water mark moves on the push that made it
342        // deepest, exactly where `epicsRingPointer` moves its own.
343        st.high_water = st.high_water.max(st.ring_used);
344        drop(st);
345        // callback.c:375 — signal the band's wake-up event.
346        self.wake.notify_one();
347        Ok(())
348    }
349
350    /// Queue a spawned future's run-queue entry. Never refused for capacity —
351    /// see [`Queued::Task`]. After shutdown `cb` is dropped un-run, which is
352    /// how the task learns it was cancelled.
353    fn schedule_task(&self, cb: Callback) {
354        let mut st = recover(FACILITY, self.state.lock());
355        if st.shutdown {
356            return;
357        }
358        st.queue.push_back(Queued::Task(cb));
359        drop(st);
360        self.wake.notify_one();
361    }
362
363    /// C `callbackQueueStatus` for one band (`callback.c:115-139`):
364    /// sample size/used/high-water/overflows, and clear the high-water
365    /// mark when `reset` is set.
366    fn stats(&self, reset: bool) -> CallbackQueueStats {
367        let mut st = recover(FACILITY, self.state.lock());
368        let out = CallbackQueueStats {
369            size: self.capacity,
370            num_used: st.ring_used,
371            max_used: st.high_water,
372            num_overflow: st.overflows,
373        };
374        if reset {
375            st.high_water = 0;
376        }
377        out
378    }
379}
380
381/// One band's row of C's `callbackQueueStats` (`callback.h`), as
382/// `callbackQueueShow` prints it.
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384pub struct CallbackQueueStats {
385    /// Ring capacity — C `stats.size`.
386    pub size: usize,
387    /// Entries queued right now — C `stats.numUsed`.
388    pub num_used: usize,
389    /// Deepest the ring has been since the last reset — C `stats.maxUsed`.
390    pub max_used: usize,
391    /// Lifetime overflow count — C `stats.numOverflow`.
392    pub num_overflow: u64,
393}
394
395/// What this facility is called when it has to report something about itself.
396const FACILITY: &str = "callback band";
397
398/// Port of `callbackTask` for one band (`callback.c:210-235`).
399fn worker_loop(pq: &PriorityQueue) {
400    loop {
401        let mut st = recover(FACILITY, pq.state.lock());
402        // callback.c:220-221 — sleep on the wake event while the ring is empty.
403        while st.queue.is_empty() && !st.shutdown {
404            st = recover(FACILITY, pq.wake.wait(st));
405        }
406        if st.queue.is_empty() {
407            // Empty *and* shutdown — drain complete, exit.
408            return;
409        }
410        // callback.c:223 — pop next entry.
411        let cb = match st.queue.pop_front().unwrap() {
412            Queued::Ring(cb) => {
413                st.ring_used -= 1;
414                // callback.c:227 — clear the overflow latch on every pop.
415                st.overflow = false;
416                cb
417            }
418            Queued::Task(cb) => cb,
419        };
420        drop(st);
421        // callback.c:228 — run the callback with the ring lock released.
422        run_isolated(FACILITY, cb);
423    }
424}
425
426/// Cheap, clonable submission side of a [`CallbackPool`] — the seam route for
427/// RTEMS synchronous-tail hand-offs (increment W3a, decision A2). Holds only
428/// `Arc`s to the bands, so cloning is free and it can be handed to the delayed
429/// timer, scanOnce worker, and future engine wiring.
430#[derive(Clone)]
431pub struct CallbackHandle {
432    queues: [Arc<PriorityQueue>; NUM_CALLBACK_PRIORITIES],
433}
434
435impl CallbackHandle {
436    /// Enqueue `cb` on `priority` — port of `callbackRequest`
437    /// (`callback.c:341`). Returns immediately; a band worker runs the
438    /// callback later. `Err` on a full ring (see [`CallbackError`]).
439    pub fn request(&self, priority: CallbackPriority, cb: Callback) -> Result<(), CallbackError> {
440        let pq = &self.queues[priority.index()];
441        pq.request(priority.name_prefix(), cb)
442    }
443
444    /// Queue a spawned future's run-queue entry on `priority`. Unlike
445    /// [`request`](Self::request) this cannot fail: the entry takes no ring
446    /// slot. The caller guarantees at most one such entry per task.
447    pub(super) fn schedule_task(&self, priority: CallbackPriority, cb: Callback) {
448        self.queues[priority.index()].schedule_task(cb);
449    }
450
451    /// Lifetime overflow count for a band — C `queueOverflows`
452    /// (`callback.c:57`).
453    pub fn overflow_count(&self, priority: CallbackPriority) -> u64 {
454        recover(FACILITY, self.queues[priority.index()].state.lock()).overflows
455    }
456
457    /// One band's `callbackQueueStatus` row (`callback.c:115-139`);
458    /// `reset` clears the high-water mark, as C's does.
459    pub fn stats(&self, priority: CallbackPriority, reset: bool) -> CallbackQueueStats {
460        self.queues[priority.index()].stats(reset)
461    }
462}
463
464/// The callback executor pool: three independent priority bands, each with its
465/// own bounded ring and worker thread(s). Port of the `callbackQueue[]` +
466/// `callbackTask` machinery in `callback.c`.
467///
468/// Dropping the pool shuts every band down and joins its workers (parity with
469/// `callbackStop`/`callbackCleanup`, `callback.c:237-284`).
470pub struct CallbackPool {
471    queues: [Arc<PriorityQueue>; NUM_CALLBACK_PRIORITIES],
472    workers: Vec<JoinHandle<()>>,
473}
474
475impl CallbackPool {
476    /// Build a pool with the C defaults: `callbackQueueSize` capacity per band
477    /// (`callback.c:51`) and `callbackThreadsDefault` worker(s) per band
478    /// (`callback.c:66`).
479    pub fn new() -> Self {
480        Self::with_per_priority_config(
481            CONFIGURED_QUEUE_SIZE.load(Ordering::Relaxed),
482            CallbackPriority::ALL.map(|p| CONFIGURED_THREADS[p.index()].load(Ordering::Relaxed)),
483        )
484    }
485
486    /// Build a pool with an explicit ring capacity and worker count per band.
487    /// `threads_per_priority` is clamped to at least 1 (C `callbackParallelThreads`
488    /// forces `count >= 1`, `callback.c:171`).
489    pub fn with_config(queue_size: usize, threads_per_priority: usize) -> Self {
490        Self::with_per_priority_config(queue_size, [threads_per_priority; NUM_CALLBACK_PRIORITIES])
491    }
492
493    /// Build a pool whose bands may carry DIFFERENT worker counts — C
494    /// `callbackQueue[i].threadsConfigured` is per band
495    /// (`callback.c:60`, `:177`, `:205`), so
496    /// `callbackParallelThreads(4, "HIGH")` widens one band only.
497    pub fn with_per_priority_config(
498        queue_size: usize,
499        threads_per_priority: [usize; NUM_CALLBACK_PRIORITIES],
500    ) -> Self {
501        let capacity = queue_size.max(1);
502        let threads_per_priority = threads_per_priority.map(|n| n.max(1));
503        let queues: [Arc<PriorityQueue>; NUM_CALLBACK_PRIORITIES] = [
504            Arc::new(PriorityQueue::new(capacity)),
505            Arc::new(PriorityQueue::new(capacity)),
506            Arc::new(PriorityQueue::new(capacity)),
507        ];
508
509        let mut workers = Vec::with_capacity(threads_per_priority.iter().sum::<usize>());
510        for prio in CallbackPriority::ALL {
511            let pq = &queues[prio.index()];
512            let threads = threads_per_priority[prio.index()];
513            for j in 0..threads {
514                // callback.c:324-327 — `cbLow` when single, `cbLow-<n>` when
515                // parallel.
516                let name = if threads > 1 {
517                    format!("{}-{}", prio.name_prefix(), j)
518                } else {
519                    prio.name_prefix().to_string()
520                };
521                let pq = Arc::clone(pq);
522                let watched_name = name.clone();
523                // A band with no worker is a band whose queued callbacks never
524                // run again — deferred record processing, delayed callbacks,
525                // monitor tails. There is no error path out of a constructor
526                // reached through a `OnceLock` initialiser, so the failure is
527                // fatal by `MandatoryThread` rather than a panic that would
528                // unwind on whichever thread happened to touch the pool first.
529                let handle = MandatoryThread::new(
530                    name,
531                    // callback.c:322 — `opts.priority = threadPriority[i]`,
532                    // applied best-effort to this OS thread.
533                    prio.os_priority(),
534                    // callback.c:323 — `opts.stackSize = epicsThreadStackBig`.
535                    StackSizeClass::Big,
536                )
537                .spawn(move || {
538                    // C `callbackTask` registers itself and removes on the way
539                    // out (`callback.c:215`, `:234`). Unbounded: a callback
540                    // band with an empty queue is parked on its semaphore, and
541                    // an idle IOC is not a fault.
542                    let _watched = crate::runtime::taskwd::taskwd_insert(
543                        watched_name,
544                        crate::runtime::taskwd::CheckIn::Unbounded,
545                        None,
546                    );
547                    run_facility_loop(
548                        FACILITY,
549                        || worker_loop(&pq),
550                        || recover(FACILITY, pq.state.lock()).shutdown = true,
551                    );
552                });
553                workers.push(handle);
554            }
555        }
556
557        CallbackPool { queues, workers }
558    }
559
560    /// A cheap, clonable submission handle (see [`CallbackHandle`]).
561    pub fn handle(&self) -> CallbackHandle {
562        CallbackHandle {
563            queues: self.queues.clone(),
564        }
565    }
566
567    /// Enqueue `cb` on `priority` — convenience wrapper over
568    /// [`CallbackHandle::request`].
569    pub fn request(&self, priority: CallbackPriority, cb: Callback) -> Result<(), CallbackError> {
570        self.queues[priority.index()].request(priority.name_prefix(), cb)
571    }
572
573    /// Lifetime overflow count for a band — C `queueOverflows`
574    /// (`callback.c:57`).
575    pub fn overflow_count(&self, priority: CallbackPriority) -> u64 {
576        recover(FACILITY, self.queues[priority.index()].state.lock()).overflows
577    }
578
579    /// One band's `callbackQueueStatus` row (`callback.c:115-139`);
580    /// `reset` clears the high-water mark, as C's does.
581    pub fn stats(&self, priority: CallbackPriority, reset: bool) -> CallbackQueueStats {
582        self.queues[priority.index()].stats(reset)
583    }
584
585    /// Stop every band and join its workers — port of the shutdown half of
586    /// `callbackStop`/`callbackCleanup` (`callback.c:237-284`). Idempotent.
587    pub fn shutdown(&mut self) {
588        for pq in &self.queues {
589            recover(FACILITY, pq.state.lock()).shutdown = true;
590            pq.wake.notify_all();
591        }
592        for w in self.workers.drain(..) {
593            let _ = w.join();
594        }
595    }
596}
597
598impl Default for CallbackPool {
599    fn default() -> Self {
600        Self::new()
601    }
602}
603
604/// A task executor of a server's own, at a priority the server chooses.
605///
606/// # Why this exists beside [`CallbackPool`]
607///
608/// `CallbackPool` is the port of `callback.c`, so its three bands carry C's
609/// thread priorities and nothing else: `epicsThreadPriorityScanLow - 1`, `+ 4`,
610/// `epicsThreadPriorityScanHigh + 1` — 59, 64, 71. That ladder is parity, not a
611/// preference, and it must not become configurable.
612///
613/// A network server's band is set against the *other servers* in the IOC, not
614/// against record processing: pvxs runs its TCP reactor at `CAServerLow-2` =
615/// 18, CA's rsrv its own ladder from `caservertask.c`. Before this type the
616/// only executor a future could be spawned onto was the callback pool, so
617/// every server's connection future ran at 64 — in the band C reserves for
618/// deferred record processing, sharing its rings. Two consequences, and the
619/// second is the one that bites: the pvxs band layout was not reproduced, and
620/// a slow connection and a deferred record tail could starve each other.
621///
622/// # One ring, whatever band is named
623///
624/// A dedicated executor has one priority by construction, so the
625/// [`CallbackPriority`] a caller names on [`handle`](Self::handle) selects
626/// nothing — all three slots are the same ring. That is deliberate: it keeps
627/// the handle type shared with the callback pool, so
628/// [`spawn_future`](crate::runtime::background::spawn_future) needs no second
629/// form, and it makes naming a band here impossible to get wrong rather than
630/// silently routing work to a ring with no worker.
631pub struct DedicatedExecutor {
632    queue: Arc<PriorityQueue>,
633    workers: Vec<JoinHandle<()>>,
634}
635
636impl DedicatedExecutor {
637    /// Start `threads` workers named `name` at `priority`.
638    ///
639    /// Fallible, unlike [`CallbackPool`]'s `MandatoryThread` workers: this
640    /// executor belongs to one server, so a thread that cannot start is that
641    /// server's `bind` failing, not the process aborting. `threads` is clamped
642    /// to at least 1 — an executor with no worker is a queue whose tasks never
643    /// run.
644    pub fn new(name: &str, priority: ThreadPriority, threads: usize) -> std::io::Result<Self> {
645        let threads = threads.max(1);
646        let queue = Arc::new(PriorityQueue::new(
647            CONFIGURED_QUEUE_SIZE.load(Ordering::Relaxed).max(1),
648        ));
649        let mut workers = Vec::with_capacity(threads);
650        for j in 0..threads {
651            // `callback.c:324-327`'s naming rule, applied to this executor's
652            // own name: bare when single, `-<n>` when parallel.
653            let worker_name = if threads > 1 {
654                format!("{name}-{j}")
655            } else {
656                name.to_string()
657            };
658            let pq = Arc::clone(&queue);
659            let watched_name = worker_name.clone();
660            let spawned = crate::runtime::task::spawn_dedicated_thread(
661                worker_name,
662                priority,
663                StackSizeClass::Big,
664                move || {
665                    let _watched = crate::runtime::taskwd::taskwd_insert(
666                        watched_name,
667                        crate::runtime::taskwd::CheckIn::Unbounded,
668                        None,
669                    );
670                    run_facility_loop(
671                        FACILITY,
672                        || worker_loop(&pq),
673                        || recover(FACILITY, pq.state.lock()).shutdown = true,
674                    );
675                },
676            );
677            match spawned {
678                Ok(handle) => workers.push(handle),
679                Err(e) => {
680                    // The workers already started have to go before the error
681                    // leaves, or they outlive the executor nobody now holds.
682                    let mut partial = DedicatedExecutor { queue, workers };
683                    partial.shutdown();
684                    return Err(e);
685                }
686            }
687        }
688        Ok(DedicatedExecutor { queue, workers })
689    }
690
691    /// A submission handle. Every band names the same ring — see the type doc.
692    pub fn handle(&self) -> CallbackHandle {
693        CallbackHandle {
694            queues: [
695                Arc::clone(&self.queue),
696                Arc::clone(&self.queue),
697                Arc::clone(&self.queue),
698            ],
699        }
700    }
701
702    /// Stop the workers and join them. Idempotent; [`Drop`] calls it.
703    pub fn shutdown(&mut self) {
704        recover(FACILITY, self.queue.state.lock()).shutdown = true;
705        self.queue.wake.notify_all();
706        for w in self.workers.drain(..) {
707            let _ = w.join();
708        }
709    }
710}
711
712impl std::fmt::Debug for DedicatedExecutor {
713    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
714        f.debug_struct("DedicatedExecutor")
715            .field("workers", &self.workers.len())
716            .finish()
717    }
718}
719
720impl Drop for DedicatedExecutor {
721    fn drop(&mut self) {
722        self.shutdown();
723    }
724}
725
726impl Drop for CallbackPool {
727    fn drop(&mut self) {
728        self.shutdown();
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use std::sync::atomic::{AtomicBool, Ordering};
736    use std::sync::mpsc;
737    use std::time::Duration;
738
739    const T: Duration = Duration::from_secs(5);
740
741    /// The property the type exists for: work submitted to a dedicated
742    /// executor runs on a thread at the priority its owner asked for, not on
743    /// C's `cbMedium` band.
744    #[test]
745    fn a_dedicated_executor_runs_its_work_at_the_priority_it_was_given() {
746        let exec = DedicatedExecutor::new("TESTEXEC", ThreadPriority::Custom(18), 1)
747            .expect("executor starts");
748        let (tx, rx) = mpsc::channel();
749        exec.handle()
750            .request(
751                CallbackPriority::Medium,
752                Box::new(move || {
753                    let name = std::thread::current()
754                        .name()
755                        .unwrap_or_default()
756                        .to_string();
757                    tx.send(name).expect("send");
758                }),
759            )
760            .expect("enqueue");
761        assert_eq!(rx.recv_timeout(T).expect("callback ran"), "TESTEXEC");
762    }
763
764    /// Naming a band on a dedicated executor selects nothing — all three reach
765    /// the one ring. A band that silently had no worker would hang instead.
766    #[test]
767    fn every_band_on_a_dedicated_executor_names_the_same_ring() {
768        let exec = DedicatedExecutor::new("BANDEXEC", ThreadPriority::Custom(18), 1)
769            .expect("executor starts");
770        for band in CallbackPriority::ALL {
771            let (tx, rx) = mpsc::channel();
772            exec.handle()
773                .request(band, Box::new(move || tx.send(()).expect("send")))
774                .expect("enqueue");
775            rx.recv_timeout(T)
776                .unwrap_or_else(|_| panic!("{band:?} reached a worker"));
777        }
778    }
779
780    /// Parallel workers get C's `callbackTask` naming rule, and all of them
781    /// drain the one ring.
782    #[test]
783    fn parallel_workers_share_the_ring_and_are_numbered() {
784        let exec = DedicatedExecutor::new("PAREXEC", ThreadPriority::Custom(18), 2)
785            .expect("executor starts");
786        let (tx, rx) = mpsc::channel();
787        for _ in 0..8 {
788            let tx = tx.clone();
789            exec.handle()
790                .request(
791                    CallbackPriority::Medium,
792                    Box::new(move || {
793                        let name = std::thread::current()
794                            .name()
795                            .unwrap_or_default()
796                            .to_string();
797                        tx.send(name).expect("send");
798                    }),
799                )
800                .expect("enqueue");
801        }
802        drop(tx);
803        let names: Vec<String> = rx.iter().take(8).collect();
804        assert_eq!(names.len(), 8, "every task ran");
805        for name in &names {
806            assert!(
807                name == "PAREXEC-0" || name == "PAREXEC-1",
808                "unexpected worker {name}"
809            );
810        }
811    }
812
813    /// `shutdown` is what `Drop` calls, so a second call must not hang on
814    /// workers that are already joined.
815    #[test]
816    fn shutting_a_dedicated_executor_down_twice_is_a_no_op() {
817        let mut exec = DedicatedExecutor::new("DUPEXEC", ThreadPriority::Custom(18), 1)
818            .expect("executor starts");
819        exec.shutdown();
820        exec.shutdown();
821        // A request after shutdown is dropped, not an error — C's rule for a
822        // stopped pool (`callback.c:237-284`).
823        assert!(
824            exec.handle()
825                .request(CallbackPriority::Medium, Box::new(|| {}))
826                .is_ok()
827        );
828    }
829
830    /// Boundary: a callback that panics. It runs on the band's own worker, so
831    /// before this one panicking callback silently retired the band and every
832    /// later callback on it — deferred processing, delayed callbacks, monitor
833    /// tails — simply never ran.
834    #[test]
835    fn a_panicking_callback_does_not_stop_the_band() {
836        let pool = CallbackPool::new();
837        pool.request(
838            CallbackPriority::Medium,
839            Box::new(|| panic!("a callback panicked on its band")),
840        )
841        .expect("enqueue the panicking callback");
842
843        let (tx, rx) = mpsc::channel();
844        pool.request(
845            CallbackPriority::Medium,
846            Box::new(move || tx.send(42u32).unwrap()),
847        )
848        .expect("enqueue the next callback");
849        assert_eq!(
850            rx.recv_timeout(T).unwrap(),
851            42,
852            "the callback after a panicking one never ran: the band worker died with it"
853        );
854    }
855
856    #[test]
857    fn enqueued_callback_runs() {
858        let pool = CallbackPool::new();
859        let (tx, rx) = mpsc::channel();
860        pool.request(
861            CallbackPriority::Medium,
862            Box::new(move || tx.send(42u32).unwrap()),
863        )
864        .unwrap();
865        assert_eq!(rx.recv_timeout(T).unwrap(), 42);
866    }
867
868    #[test]
869    fn priority_bands_are_independent() {
870        // Invariant: a blocked Low worker MUST NOT stall the High band.
871        let pool = CallbackPool::new();
872
873        let (started_tx, started_rx) = mpsc::channel();
874        let (gate_tx, gate_rx) = mpsc::channel::<()>();
875        // Occupy the single Low worker and hold it inside the callback.
876        pool.request(
877            CallbackPriority::Low,
878            Box::new(move || {
879                started_tx.send(()).unwrap();
880                gate_rx.recv().unwrap();
881            }),
882        )
883        .unwrap();
884        started_rx.recv_timeout(T).unwrap(); // Low worker is now blocked.
885
886        // High must still run despite Low being wedged.
887        let (high_tx, high_rx) = mpsc::channel();
888        pool.request(
889            CallbackPriority::High,
890            Box::new(move || high_tx.send(()).unwrap()),
891        )
892        .unwrap();
893        high_rx
894            .recv_timeout(T)
895            .expect("High band stalled behind a blocked Low worker");
896
897        gate_tx.send(()).unwrap(); // release Low so shutdown can join.
898    }
899
900    #[test]
901    fn full_ring_latches_overflow_then_recovers() {
902        // Boundary: capacity-1 ring, worker pinned busy → the second live
903        // entry fills the ring, the third latches overflow (callback.c:365).
904        let mut pool = CallbackPool::with_config(1, 1);
905        let (started_tx, started_rx) = mpsc::channel();
906        let (gate_tx, gate_rx) = mpsc::channel::<()>();
907
908        // Worker picks this up and blocks; ring is now empty again.
909        pool.request(
910            CallbackPriority::Low,
911            Box::new(move || {
912                started_tx.send(()).unwrap();
913                gate_rx.recv().unwrap();
914            }),
915        )
916        .unwrap();
917        started_rx.recv_timeout(T).unwrap();
918
919        // Fill the single ring slot (worker is busy, cannot drain).
920        pool.request(CallbackPriority::Low, Box::new(|| {}))
921            .unwrap();
922        // Next push finds the ring full → QueueFull + overflow latched.
923        assert_eq!(
924            pool.request(CallbackPriority::Low, Box::new(|| {})),
925            Err(CallbackError::QueueFull)
926        );
927        // While latched, even a would-fit push is rejected (callback.c:365).
928        assert_eq!(
929            pool.request(CallbackPriority::Low, Box::new(|| {})),
930            Err(CallbackError::QueueFull)
931        );
932        assert_eq!(pool.overflow_count(CallbackPriority::Low), 1);
933
934        gate_tx.send(()).unwrap(); // release the worker so it drains + clears.
935        pool.shutdown();
936    }
937
938    #[test]
939    fn request_after_shutdown_is_silent_noop() {
940        // Boundary: a CallbackHandle that outlives the pool (the delayed-timer
941        // teardown race) must get Ok(()) and the callback must never run.
942        let pool = CallbackPool::new();
943        let h = pool.handle();
944        drop(pool); // sets shutdown on every band, joins workers.
945
946        let ran = Arc::new(AtomicBool::new(false));
947        let r = Arc::clone(&ran);
948        let res = h.request(
949            CallbackPriority::High,
950            Box::new(move || r.store(true, Ordering::SeqCst)),
951        );
952        assert_eq!(res, Ok(())); // silent no-op, not Err.
953        assert!(
954            !ran.load(Ordering::SeqCst),
955            "callback ran after shutdown; it must be dropped, not invoked"
956        );
957    }
958
959    /// `cpu_count()` must report the processors the calling thread may
960    /// actually run on, not the host's — epics-base `556de06ff`, which the
961    /// reference pin R7.0.10 does not carry (see [`cpu_count`]). At the pin
962    /// this returns the host count for a pinned thread, which is exactly the
963    /// overreporting that commit removed.
964    ///
965    /// The mask is set on a thread of this test's own: on Linux affinity is
966    /// per-thread and `sched_getaffinity(0, ..)` — what
967    /// `available_parallelism` calls — reads the caller's, so no other
968    /// test's thread is disturbed.
969    #[cfg(target_os = "linux")]
970    #[test]
971    fn cpu_count_respects_the_threads_affinity_mask() {
972        let host = cpu_count();
973        if host < 2 {
974            // Already pinned to one processor: nothing left to restrict, and
975            // the assertion below would hold for the pin's behaviour too.
976            return;
977        }
978        let pinned = std::thread::spawn(|| {
979            // SAFETY: both calls address pid 0 (this thread) and a
980            // `cpu_set_t` owned by this frame; nothing else is observed or
981            // mutated.
982            unsafe {
983                let mut have: libc::cpu_set_t = std::mem::zeroed();
984                if libc::sched_getaffinity(0, size_of::<libc::cpu_set_t>(), &mut have) != 0 {
985                    return None;
986                }
987                // Keep the lowest processor already permitted — CPU 0 need
988                // not be in the mask this process inherited.
989                let first = (0..libc::CPU_SETSIZE as usize).find(|&c| libc::CPU_ISSET(c, &have))?;
990                let mut one: libc::cpu_set_t = std::mem::zeroed();
991                libc::CPU_ZERO(&mut one);
992                libc::CPU_SET(first, &mut one);
993                if libc::sched_setaffinity(0, size_of::<libc::cpu_set_t>(), &one) != 0 {
994                    return None;
995                }
996            }
997            Some(cpu_count())
998        })
999        .join()
1000        .expect("the pinned thread must not panic");
1001        let Some(pinned) = pinned else {
1002            // The sandbox forbids setting affinity; nothing measurable here.
1003            return;
1004        };
1005        assert_eq!(
1006            pinned, 1,
1007            "cpu_count() reported {host} for a thread pinned to one \
1008             processor — that is the pre-556de06ff sysconf behaviour"
1009        );
1010    }
1011}