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/// Mutable, lock-guarded state of one priority band's ring.
248struct QueueState {
249    queue: VecDeque<Callback>,
250    /// C `epicsRingPointerGetHighWaterMark` on the band's ring — the
251    /// deepest the queue has ever been. `callbackQueueShow` reports it
252    /// and `callbackQueueStatus(reset=1)` clears it
253    /// (`callback.c:115-139`), so it is not derivable from `queue.len()`
254    /// after the fact and has to be latched on every push.
255    high_water: usize,
256    /// C `cbQueueSet.queueOverflow` — latched full flag (`callback.c:56`).
257    overflow: bool,
258    /// C `cbQueueSet.queueOverflows` — lifetime overflow count
259    /// (`callback.c:57`).
260    overflows: u64,
261    shutdown: bool,
262}
263
264/// One priority band: a bounded ring plus its wake-up condvar. Mirrors C
265/// `cbQueueSet` (`callback.c:53-62`).
266struct PriorityQueue {
267    capacity: usize,
268    state: Mutex<QueueState>,
269    /// C `cbQueueSet.semWakeUp` (`callback.c:54`).
270    wake: Condvar,
271}
272
273impl PriorityQueue {
274    fn new(capacity: usize) -> Self {
275        PriorityQueue {
276            capacity,
277            state: Mutex::new(QueueState {
278                queue: VecDeque::with_capacity(capacity.min(1024)),
279                high_water: 0,
280                overflow: false,
281                overflows: 0,
282                shutdown: false,
283            }),
284            wake: Condvar::new(),
285        }
286    }
287
288    /// Port of `callbackRequest` for a single band (`callback.c:341-377`).
289    fn request(&self, name: &str, cb: Callback) -> Result<(), CallbackError> {
290        let mut st = recover(FACILITY, self.state.lock());
291        if st.shutdown {
292            // Pool stopped: C drops late callbackRequests after callbackStop
293            // without surfacing an error (`callback.c:237-284`). Drop `cb`
294            // (deallocated here, never invoked) and report success. This also
295            // absorbs the teardown race where the delayed timer fires into a
296            // pool that has just been dropped.
297            drop(st);
298            tracing::trace!(
299                target: "epics_base_rs::runtime::callback",
300                band = name,
301                "callbackRequest after shutdown dropped"
302            );
303            return Ok(());
304        }
305        // callback.c:365 — reject immediately while the overflow latch is set.
306        if st.overflow {
307            return Err(CallbackError::QueueFull);
308        }
309        // callback.c:367-374 — push; on a full ring, latch overflow and count.
310        if st.queue.len() >= self.capacity {
311            st.overflow = true;
312            st.overflows += 1;
313            // callback.c:370 — `fullMessage[priority]`, printed once per
314            // overflow episode (the latch above suppresses repeats).
315            tracing::error!(
316                target: "epics_base_rs::runtime::callback",
317                band = name,
318                "callbackRequest: ERROR {} ring buffer full",
319                name
320            );
321            return Err(CallbackError::QueueFull);
322        }
323        st.queue.push_back(cb);
324        // The ring's high-water mark moves on the push that made it
325        // deepest, exactly where `epicsRingPointer` moves its own.
326        st.high_water = st.high_water.max(st.queue.len());
327        drop(st);
328        // callback.c:375 — signal the band's wake-up event.
329        self.wake.notify_one();
330        Ok(())
331    }
332
333    /// C `callbackQueueStatus` for one band (`callback.c:115-139`):
334    /// sample size/used/high-water/overflows, and clear the high-water
335    /// mark when `reset` is set.
336    fn stats(&self, reset: bool) -> CallbackQueueStats {
337        let mut st = recover(FACILITY, self.state.lock());
338        let out = CallbackQueueStats {
339            size: self.capacity,
340            num_used: st.queue.len(),
341            max_used: st.high_water,
342            num_overflow: st.overflows,
343        };
344        if reset {
345            st.high_water = 0;
346        }
347        out
348    }
349}
350
351/// One band's row of C's `callbackQueueStats` (`callback.h`), as
352/// `callbackQueueShow` prints it.
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub struct CallbackQueueStats {
355    /// Ring capacity — C `stats.size`.
356    pub size: usize,
357    /// Entries queued right now — C `stats.numUsed`.
358    pub num_used: usize,
359    /// Deepest the ring has been since the last reset — C `stats.maxUsed`.
360    pub max_used: usize,
361    /// Lifetime overflow count — C `stats.numOverflow`.
362    pub num_overflow: u64,
363}
364
365/// What this facility is called when it has to report something about itself.
366const FACILITY: &str = "callback band";
367
368/// Port of `callbackTask` for one band (`callback.c:210-235`).
369fn worker_loop(pq: &PriorityQueue) {
370    loop {
371        let mut st = recover(FACILITY, pq.state.lock());
372        // callback.c:220-221 — sleep on the wake event while the ring is empty.
373        while st.queue.is_empty() && !st.shutdown {
374            st = recover(FACILITY, pq.wake.wait(st));
375        }
376        if st.queue.is_empty() {
377            // Empty *and* shutdown — drain complete, exit.
378            return;
379        }
380        // callback.c:223 — pop next entry.
381        let cb = st.queue.pop_front().unwrap();
382        // callback.c:227 — clear the overflow latch on every pop.
383        st.overflow = false;
384        drop(st);
385        // callback.c:228 — run the callback with the ring lock released.
386        run_isolated(FACILITY, cb);
387    }
388}
389
390/// Cheap, clonable submission side of a [`CallbackPool`] — the seam route for
391/// RTEMS synchronous-tail hand-offs (increment W3a, decision A2). Holds only
392/// `Arc`s to the bands, so cloning is free and it can be handed to the delayed
393/// timer, scanOnce worker, and future engine wiring.
394#[derive(Clone)]
395pub struct CallbackHandle {
396    queues: [Arc<PriorityQueue>; NUM_CALLBACK_PRIORITIES],
397}
398
399impl CallbackHandle {
400    /// Enqueue `cb` on `priority` — port of `callbackRequest`
401    /// (`callback.c:341`). Returns immediately; a band worker runs the
402    /// callback later. `Err` on a full ring (see [`CallbackError`]).
403    pub fn request(&self, priority: CallbackPriority, cb: Callback) -> Result<(), CallbackError> {
404        let pq = &self.queues[priority.index()];
405        pq.request(priority.name_prefix(), cb)
406    }
407
408    /// Lifetime overflow count for a band — C `queueOverflows`
409    /// (`callback.c:57`).
410    pub fn overflow_count(&self, priority: CallbackPriority) -> u64 {
411        recover(FACILITY, self.queues[priority.index()].state.lock()).overflows
412    }
413
414    /// One band's `callbackQueueStatus` row (`callback.c:115-139`);
415    /// `reset` clears the high-water mark, as C's does.
416    pub fn stats(&self, priority: CallbackPriority, reset: bool) -> CallbackQueueStats {
417        self.queues[priority.index()].stats(reset)
418    }
419}
420
421/// The callback executor pool: three independent priority bands, each with its
422/// own bounded ring and worker thread(s). Port of the `callbackQueue[]` +
423/// `callbackTask` machinery in `callback.c`.
424///
425/// Dropping the pool shuts every band down and joins its workers (parity with
426/// `callbackStop`/`callbackCleanup`, `callback.c:237-284`).
427pub struct CallbackPool {
428    queues: [Arc<PriorityQueue>; NUM_CALLBACK_PRIORITIES],
429    workers: Vec<JoinHandle<()>>,
430}
431
432impl CallbackPool {
433    /// Build a pool with the C defaults: `callbackQueueSize` capacity per band
434    /// (`callback.c:51`) and `callbackThreadsDefault` worker(s) per band
435    /// (`callback.c:66`).
436    pub fn new() -> Self {
437        Self::with_per_priority_config(
438            CONFIGURED_QUEUE_SIZE.load(Ordering::Relaxed),
439            CallbackPriority::ALL.map(|p| CONFIGURED_THREADS[p.index()].load(Ordering::Relaxed)),
440        )
441    }
442
443    /// Build a pool with an explicit ring capacity and worker count per band.
444    /// `threads_per_priority` is clamped to at least 1 (C `callbackParallelThreads`
445    /// forces `count >= 1`, `callback.c:171`).
446    pub fn with_config(queue_size: usize, threads_per_priority: usize) -> Self {
447        Self::with_per_priority_config(queue_size, [threads_per_priority; NUM_CALLBACK_PRIORITIES])
448    }
449
450    /// Build a pool whose bands may carry DIFFERENT worker counts — C
451    /// `callbackQueue[i].threadsConfigured` is per band
452    /// (`callback.c:60`, `:177`, `:205`), so
453    /// `callbackParallelThreads(4, "HIGH")` widens one band only.
454    pub fn with_per_priority_config(
455        queue_size: usize,
456        threads_per_priority: [usize; NUM_CALLBACK_PRIORITIES],
457    ) -> Self {
458        let capacity = queue_size.max(1);
459        let threads_per_priority = threads_per_priority.map(|n| n.max(1));
460        let queues: [Arc<PriorityQueue>; NUM_CALLBACK_PRIORITIES] = [
461            Arc::new(PriorityQueue::new(capacity)),
462            Arc::new(PriorityQueue::new(capacity)),
463            Arc::new(PriorityQueue::new(capacity)),
464        ];
465
466        let mut workers = Vec::with_capacity(threads_per_priority.iter().sum::<usize>());
467        for prio in CallbackPriority::ALL {
468            let pq = &queues[prio.index()];
469            let threads = threads_per_priority[prio.index()];
470            for j in 0..threads {
471                // callback.c:324-327 — `cbLow` when single, `cbLow-<n>` when
472                // parallel.
473                let name = if threads > 1 {
474                    format!("{}-{}", prio.name_prefix(), j)
475                } else {
476                    prio.name_prefix().to_string()
477                };
478                let pq = Arc::clone(pq);
479                let watched_name = name.clone();
480                // A band with no worker is a band whose queued callbacks never
481                // run again — deferred record processing, delayed callbacks,
482                // monitor tails. There is no error path out of a constructor
483                // reached through a `OnceLock` initialiser, so the failure is
484                // fatal by `MandatoryThread` rather than a panic that would
485                // unwind on whichever thread happened to touch the pool first.
486                let handle = MandatoryThread::new(
487                    name,
488                    // callback.c:322 — `opts.priority = threadPriority[i]`,
489                    // applied best-effort to this OS thread.
490                    prio.os_priority(),
491                    // callback.c:323 — `opts.stackSize = epicsThreadStackBig`.
492                    StackSizeClass::Big,
493                )
494                .spawn(move || {
495                    // C `callbackTask` registers itself and removes on the way
496                    // out (`callback.c:215`, `:234`). Unbounded: a callback
497                    // band with an empty queue is parked on its semaphore, and
498                    // an idle IOC is not a fault.
499                    let _watched = crate::runtime::taskwd::taskwd_insert(
500                        watched_name,
501                        crate::runtime::taskwd::CheckIn::Unbounded,
502                        None,
503                    );
504                    run_facility_loop(
505                        FACILITY,
506                        || worker_loop(&pq),
507                        || recover(FACILITY, pq.state.lock()).shutdown = true,
508                    );
509                });
510                workers.push(handle);
511            }
512        }
513
514        CallbackPool { queues, workers }
515    }
516
517    /// A cheap, clonable submission handle (see [`CallbackHandle`]).
518    pub fn handle(&self) -> CallbackHandle {
519        CallbackHandle {
520            queues: self.queues.clone(),
521        }
522    }
523
524    /// Enqueue `cb` on `priority` — convenience wrapper over
525    /// [`CallbackHandle::request`].
526    pub fn request(&self, priority: CallbackPriority, cb: Callback) -> Result<(), CallbackError> {
527        self.queues[priority.index()].request(priority.name_prefix(), cb)
528    }
529
530    /// Lifetime overflow count for a band — C `queueOverflows`
531    /// (`callback.c:57`).
532    pub fn overflow_count(&self, priority: CallbackPriority) -> u64 {
533        recover(FACILITY, self.queues[priority.index()].state.lock()).overflows
534    }
535
536    /// One band's `callbackQueueStatus` row (`callback.c:115-139`);
537    /// `reset` clears the high-water mark, as C's does.
538    pub fn stats(&self, priority: CallbackPriority, reset: bool) -> CallbackQueueStats {
539        self.queues[priority.index()].stats(reset)
540    }
541
542    /// Stop every band and join its workers — port of the shutdown half of
543    /// `callbackStop`/`callbackCleanup` (`callback.c:237-284`). Idempotent.
544    pub fn shutdown(&mut self) {
545        for pq in &self.queues {
546            recover(FACILITY, pq.state.lock()).shutdown = true;
547            pq.wake.notify_all();
548        }
549        for w in self.workers.drain(..) {
550            let _ = w.join();
551        }
552    }
553}
554
555impl Default for CallbackPool {
556    fn default() -> Self {
557        Self::new()
558    }
559}
560
561impl Drop for CallbackPool {
562    fn drop(&mut self) {
563        self.shutdown();
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use std::sync::atomic::{AtomicBool, Ordering};
571    use std::sync::mpsc;
572    use std::time::Duration;
573
574    const T: Duration = Duration::from_secs(5);
575
576    /// Boundary: a callback that panics. It runs on the band's own worker, so
577    /// before this one panicking callback silently retired the band and every
578    /// later callback on it — deferred processing, delayed callbacks, monitor
579    /// tails — simply never ran.
580    #[test]
581    fn a_panicking_callback_does_not_stop_the_band() {
582        let pool = CallbackPool::new();
583        pool.request(
584            CallbackPriority::Medium,
585            Box::new(|| panic!("a callback panicked on its band")),
586        )
587        .expect("enqueue the panicking callback");
588
589        let (tx, rx) = mpsc::channel();
590        pool.request(
591            CallbackPriority::Medium,
592            Box::new(move || tx.send(42u32).unwrap()),
593        )
594        .expect("enqueue the next callback");
595        assert_eq!(
596            rx.recv_timeout(T).unwrap(),
597            42,
598            "the callback after a panicking one never ran: the band worker died with it"
599        );
600    }
601
602    #[test]
603    fn enqueued_callback_runs() {
604        let pool = CallbackPool::new();
605        let (tx, rx) = mpsc::channel();
606        pool.request(
607            CallbackPriority::Medium,
608            Box::new(move || tx.send(42u32).unwrap()),
609        )
610        .unwrap();
611        assert_eq!(rx.recv_timeout(T).unwrap(), 42);
612    }
613
614    #[test]
615    fn priority_bands_are_independent() {
616        // Invariant: a blocked Low worker MUST NOT stall the High band.
617        let pool = CallbackPool::new();
618
619        let (started_tx, started_rx) = mpsc::channel();
620        let (gate_tx, gate_rx) = mpsc::channel::<()>();
621        // Occupy the single Low worker and hold it inside the callback.
622        pool.request(
623            CallbackPriority::Low,
624            Box::new(move || {
625                started_tx.send(()).unwrap();
626                gate_rx.recv().unwrap();
627            }),
628        )
629        .unwrap();
630        started_rx.recv_timeout(T).unwrap(); // Low worker is now blocked.
631
632        // High must still run despite Low being wedged.
633        let (high_tx, high_rx) = mpsc::channel();
634        pool.request(
635            CallbackPriority::High,
636            Box::new(move || high_tx.send(()).unwrap()),
637        )
638        .unwrap();
639        high_rx
640            .recv_timeout(T)
641            .expect("High band stalled behind a blocked Low worker");
642
643        gate_tx.send(()).unwrap(); // release Low so shutdown can join.
644    }
645
646    #[test]
647    fn full_ring_latches_overflow_then_recovers() {
648        // Boundary: capacity-1 ring, worker pinned busy → the second live
649        // entry fills the ring, the third latches overflow (callback.c:365).
650        let mut pool = CallbackPool::with_config(1, 1);
651        let (started_tx, started_rx) = mpsc::channel();
652        let (gate_tx, gate_rx) = mpsc::channel::<()>();
653
654        // Worker picks this up and blocks; ring is now empty again.
655        pool.request(
656            CallbackPriority::Low,
657            Box::new(move || {
658                started_tx.send(()).unwrap();
659                gate_rx.recv().unwrap();
660            }),
661        )
662        .unwrap();
663        started_rx.recv_timeout(T).unwrap();
664
665        // Fill the single ring slot (worker is busy, cannot drain).
666        pool.request(CallbackPriority::Low, Box::new(|| {}))
667            .unwrap();
668        // Next push finds the ring full → QueueFull + overflow latched.
669        assert_eq!(
670            pool.request(CallbackPriority::Low, Box::new(|| {})),
671            Err(CallbackError::QueueFull)
672        );
673        // While latched, even a would-fit push is rejected (callback.c:365).
674        assert_eq!(
675            pool.request(CallbackPriority::Low, Box::new(|| {})),
676            Err(CallbackError::QueueFull)
677        );
678        assert_eq!(pool.overflow_count(CallbackPriority::Low), 1);
679
680        gate_tx.send(()).unwrap(); // release the worker so it drains + clears.
681        pool.shutdown();
682    }
683
684    #[test]
685    fn request_after_shutdown_is_silent_noop() {
686        // Boundary: a CallbackHandle that outlives the pool (the delayed-timer
687        // teardown race) must get Ok(()) and the callback must never run.
688        let pool = CallbackPool::new();
689        let h = pool.handle();
690        drop(pool); // sets shutdown on every band, joins workers.
691
692        let ran = Arc::new(AtomicBool::new(false));
693        let r = Arc::clone(&ran);
694        let res = h.request(
695            CallbackPriority::High,
696            Box::new(move || r.store(true, Ordering::SeqCst)),
697        );
698        assert_eq!(res, Ok(())); // silent no-op, not Err.
699        assert!(
700            !ran.load(Ordering::SeqCst),
701            "callback ran after shutdown; it must be dropped, not invoked"
702        );
703    }
704
705    /// `cpu_count()` must report the processors the calling thread may
706    /// actually run on, not the host's — epics-base `556de06ff`, which the
707    /// reference pin R7.0.10 does not carry (see [`cpu_count`]). At the pin
708    /// this returns the host count for a pinned thread, which is exactly the
709    /// overreporting that commit removed.
710    ///
711    /// The mask is set on a thread of this test's own: on Linux affinity is
712    /// per-thread and `sched_getaffinity(0, ..)` — what
713    /// `available_parallelism` calls — reads the caller's, so no other
714    /// test's thread is disturbed.
715    #[cfg(target_os = "linux")]
716    #[test]
717    fn cpu_count_respects_the_threads_affinity_mask() {
718        let host = cpu_count();
719        if host < 2 {
720            // Already pinned to one processor: nothing left to restrict, and
721            // the assertion below would hold for the pin's behaviour too.
722            return;
723        }
724        let pinned = std::thread::spawn(|| {
725            // SAFETY: both calls address pid 0 (this thread) and a
726            // `cpu_set_t` owned by this frame; nothing else is observed or
727            // mutated.
728            unsafe {
729                let mut have: libc::cpu_set_t = std::mem::zeroed();
730                if libc::sched_getaffinity(0, size_of::<libc::cpu_set_t>(), &mut have) != 0 {
731                    return None;
732                }
733                // Keep the lowest processor already permitted — CPU 0 need
734                // not be in the mask this process inherited.
735                let first = (0..libc::CPU_SETSIZE as usize).find(|&c| libc::CPU_ISSET(c, &have))?;
736                let mut one: libc::cpu_set_t = std::mem::zeroed();
737                libc::CPU_ZERO(&mut one);
738                libc::CPU_SET(first, &mut one);
739                if libc::sched_setaffinity(0, size_of::<libc::cpu_set_t>(), &one) != 0 {
740                    return None;
741                }
742            }
743            Some(cpu_count())
744        })
745        .join()
746        .expect("the pinned thread must not panic");
747        let Some(pinned) = pinned else {
748            // The sandbox forbids setting affinity; nothing measurable here.
749            return;
750        };
751        assert_eq!(
752            pinned, 1,
753            "cpu_count() reported {host} for a thread pinned to one \
754             processor — that is the pre-556de06ff sysconf behaviour"
755        );
756    }
757}