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}