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}