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::{Arc, Condvar, Mutex};
33use std::thread::JoinHandle;
34
35use super::facility::{recover, run_facility_loop, run_isolated};
36use crate::runtime::task::{MandatoryThread, StackSizeClass, ThreadPriority};
37
38/// A unit of deferred work. The C `epicsCallback` is a function pointer plus
39/// user data; the Rust port boxes a `FnOnce` closure that already captures its
40/// context.
41pub type Callback = Box<dyn FnOnce() + Send + 'static>;
42
43/// Number of callback priority bands — C `NUM_CALLBACK_PRIORITIES`
44/// (`callback.h:40`).
45pub const NUM_CALLBACK_PRIORITIES: usize = 3;
46
47/// Default per-band ring capacity — C `callbackQueueSize` (`callback.c:51`).
48pub const DEFAULT_QUEUE_SIZE: usize = 2000;
49
50/// Default worker threads per band — C `callbackThreadsDefault`
51/// (`callback.c:66`).
52pub const DEFAULT_THREADS_PER_PRIORITY: usize = 1;
53
54/// Callback priority band — C `priorityLow`/`priorityMedium`/`priorityHigh`
55/// (`callback.h:41-43`).
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
57pub enum CallbackPriority {
58 /// `priorityLow` (0).
59 Low,
60 /// `priorityMedium` (1).
61 Medium,
62 /// `priorityHigh` (2).
63 High,
64}
65
66impl CallbackPriority {
67 /// All bands in index order — mirrors the `for (i = 0; i <
68 /// NUM_CALLBACK_PRIORITIES; i++)` loops in `callback.c`.
69 pub const ALL: [CallbackPriority; NUM_CALLBACK_PRIORITIES] = [
70 CallbackPriority::Low,
71 CallbackPriority::Medium,
72 CallbackPriority::High,
73 ];
74
75 /// The `0..3` band index — C `priorityValue` (`callback.c:98`).
76 pub fn index(self) -> usize {
77 match self {
78 CallbackPriority::Low => 0,
79 CallbackPriority::Medium => 1,
80 CallbackPriority::High => 2,
81 }
82 }
83
84 /// Worker-thread name prefix — C `threadNamePrefix` (`callback.c:86-88`).
85 pub fn name_prefix(self) -> &'static str {
86 match self {
87 CallbackPriority::Low => "cbLow",
88 CallbackPriority::Medium => "cbMedium",
89 CallbackPriority::High => "cbHigh",
90 }
91 }
92
93 /// OS thread priority for this band — C `threadPriority`
94 /// (`callback.c:93-97`): `epicsThreadPriorityScanLow - 1`,
95 /// `epicsThreadPriorityScanLow + 4`, `epicsThreadPriorityScanHigh + 1`.
96 /// Values are derived from [`ThreadPriority`] so the parity link to
97 /// `epicsThread.h` stays in one place.
98 pub fn os_priority(self) -> ThreadPriority {
99 let scan_low = ThreadPriority::ScanLow.value(); // 60 (epicsThread.h:84)
100 let scan_high = ThreadPriority::ScanHigh.value(); // 70 (epicsThread.h:85)
101 match self {
102 CallbackPriority::Low => ThreadPriority::Custom(scan_low - 1),
103 CallbackPriority::Medium => ThreadPriority::Custom(scan_low + 4),
104 CallbackPriority::High => ThreadPriority::Custom(scan_high + 1),
105 }
106 }
107}
108
109/// Why a [`CallbackHandle::request`] was rejected.
110///
111/// Note there is no `Shutdown` variant: a request arriving after the pool has
112/// stopped is a silent no-op returning `Ok(())`, matching C — `callbackStop`
113/// halts the queues and late `callbackRequest`s are simply dropped without
114/// surfacing an error to the caller (`callback.c:237-284`).
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum CallbackError {
117 /// The band's ring was full — C `S_db_bufFull` (`callback.c:373`). Either
118 /// the push found the ring at capacity, or the overflow latch is still set
119 /// from a prior full push (`callback.c:365`).
120 QueueFull,
121}
122
123/// Mutable, lock-guarded state of one priority band's ring.
124struct QueueState {
125 queue: VecDeque<Callback>,
126 /// C `cbQueueSet.queueOverflow` — latched full flag (`callback.c:56`).
127 overflow: bool,
128 /// C `cbQueueSet.queueOverflows` — lifetime overflow count
129 /// (`callback.c:57`).
130 overflows: u64,
131 shutdown: bool,
132}
133
134/// One priority band: a bounded ring plus its wake-up condvar. Mirrors C
135/// `cbQueueSet` (`callback.c:53-62`).
136struct PriorityQueue {
137 capacity: usize,
138 state: Mutex<QueueState>,
139 /// C `cbQueueSet.semWakeUp` (`callback.c:54`).
140 wake: Condvar,
141}
142
143impl PriorityQueue {
144 fn new(capacity: usize) -> Self {
145 PriorityQueue {
146 capacity,
147 state: Mutex::new(QueueState {
148 queue: VecDeque::with_capacity(capacity.min(1024)),
149 overflow: false,
150 overflows: 0,
151 shutdown: false,
152 }),
153 wake: Condvar::new(),
154 }
155 }
156
157 /// Port of `callbackRequest` for a single band (`callback.c:341-377`).
158 fn request(&self, name: &str, cb: Callback) -> Result<(), CallbackError> {
159 let mut st = recover(FACILITY, self.state.lock());
160 if st.shutdown {
161 // Pool stopped: C drops late callbackRequests after callbackStop
162 // without surfacing an error (`callback.c:237-284`). Drop `cb`
163 // (deallocated here, never invoked) and report success. This also
164 // absorbs the teardown race where the delayed timer fires into a
165 // pool that has just been dropped.
166 drop(st);
167 tracing::trace!(
168 target: "epics_base_rs::runtime::callback",
169 band = name,
170 "callbackRequest after shutdown dropped"
171 );
172 return Ok(());
173 }
174 // callback.c:365 — reject immediately while the overflow latch is set.
175 if st.overflow {
176 return Err(CallbackError::QueueFull);
177 }
178 // callback.c:367-374 — push; on a full ring, latch overflow and count.
179 if st.queue.len() >= self.capacity {
180 st.overflow = true;
181 st.overflows += 1;
182 // callback.c:370 — `fullMessage[priority]`, printed once per
183 // overflow episode (the latch above suppresses repeats).
184 tracing::error!(
185 target: "epics_base_rs::runtime::callback",
186 band = name,
187 "callbackRequest: ERROR {} ring buffer full",
188 name
189 );
190 return Err(CallbackError::QueueFull);
191 }
192 st.queue.push_back(cb);
193 drop(st);
194 // callback.c:375 — signal the band's wake-up event.
195 self.wake.notify_one();
196 Ok(())
197 }
198}
199
200/// What this facility is called when it has to report something about itself.
201const FACILITY: &str = "callback band";
202
203/// Port of `callbackTask` for one band (`callback.c:210-235`).
204fn worker_loop(pq: &PriorityQueue) {
205 loop {
206 let mut st = recover(FACILITY, pq.state.lock());
207 // callback.c:220-221 — sleep on the wake event while the ring is empty.
208 while st.queue.is_empty() && !st.shutdown {
209 st = recover(FACILITY, pq.wake.wait(st));
210 }
211 if st.queue.is_empty() {
212 // Empty *and* shutdown — drain complete, exit.
213 return;
214 }
215 // callback.c:223 — pop next entry.
216 let cb = st.queue.pop_front().unwrap();
217 // callback.c:227 — clear the overflow latch on every pop.
218 st.overflow = false;
219 drop(st);
220 // callback.c:228 — run the callback with the ring lock released.
221 run_isolated(FACILITY, cb);
222 }
223}
224
225/// Cheap, clonable submission side of a [`CallbackPool`] — the seam route for
226/// RTEMS synchronous-tail hand-offs (increment W3a, decision A2). Holds only
227/// `Arc`s to the bands, so cloning is free and it can be handed to the delayed
228/// timer, scanOnce worker, and future engine wiring.
229#[derive(Clone)]
230pub struct CallbackHandle {
231 queues: [Arc<PriorityQueue>; NUM_CALLBACK_PRIORITIES],
232}
233
234impl CallbackHandle {
235 /// Enqueue `cb` on `priority` — port of `callbackRequest`
236 /// (`callback.c:341`). Returns immediately; a band worker runs the
237 /// callback later. `Err` on a full ring (see [`CallbackError`]).
238 pub fn request(&self, priority: CallbackPriority, cb: Callback) -> Result<(), CallbackError> {
239 let pq = &self.queues[priority.index()];
240 pq.request(priority.name_prefix(), cb)
241 }
242
243 /// Lifetime overflow count for a band — C `queueOverflows`
244 /// (`callback.c:57`).
245 pub fn overflow_count(&self, priority: CallbackPriority) -> u64 {
246 recover(FACILITY, self.queues[priority.index()].state.lock()).overflows
247 }
248}
249
250/// The callback executor pool: three independent priority bands, each with its
251/// own bounded ring and worker thread(s). Port of the `callbackQueue[]` +
252/// `callbackTask` machinery in `callback.c`.
253///
254/// Dropping the pool shuts every band down and joins its workers (parity with
255/// `callbackStop`/`callbackCleanup`, `callback.c:237-284`).
256pub struct CallbackPool {
257 queues: [Arc<PriorityQueue>; NUM_CALLBACK_PRIORITIES],
258 workers: Vec<JoinHandle<()>>,
259}
260
261impl CallbackPool {
262 /// Build a pool with the C defaults: `callbackQueueSize` capacity per band
263 /// (`callback.c:51`) and `callbackThreadsDefault` worker(s) per band
264 /// (`callback.c:66`).
265 pub fn new() -> Self {
266 Self::with_config(DEFAULT_QUEUE_SIZE, DEFAULT_THREADS_PER_PRIORITY)
267 }
268
269 /// Build a pool with an explicit ring capacity and worker count per band.
270 /// `threads_per_priority` is clamped to at least 1 (C `callbackParallelThreads`
271 /// forces `count >= 1`, `callback.c:171`).
272 pub fn with_config(queue_size: usize, threads_per_priority: usize) -> Self {
273 let capacity = queue_size.max(1);
274 let threads = threads_per_priority.max(1);
275 let queues: [Arc<PriorityQueue>; NUM_CALLBACK_PRIORITIES] = [
276 Arc::new(PriorityQueue::new(capacity)),
277 Arc::new(PriorityQueue::new(capacity)),
278 Arc::new(PriorityQueue::new(capacity)),
279 ];
280
281 let mut workers = Vec::with_capacity(NUM_CALLBACK_PRIORITIES * threads);
282 for prio in CallbackPriority::ALL {
283 let pq = &queues[prio.index()];
284 for j in 0..threads {
285 // callback.c:324-327 — `cbLow` when single, `cbLow-<n>` when
286 // parallel.
287 let name = if threads > 1 {
288 format!("{}-{}", prio.name_prefix(), j)
289 } else {
290 prio.name_prefix().to_string()
291 };
292 let pq = Arc::clone(pq);
293 // A band with no worker is a band whose queued callbacks never
294 // run again — deferred record processing, delayed callbacks,
295 // monitor tails. There is no error path out of a constructor
296 // reached through a `OnceLock` initialiser, so the failure is
297 // fatal by `MandatoryThread` rather than a panic that would
298 // unwind on whichever thread happened to touch the pool first.
299 let handle = MandatoryThread::new(
300 name,
301 // callback.c:322 — `opts.priority = threadPriority[i]`,
302 // applied best-effort to this OS thread.
303 prio.os_priority(),
304 // callback.c:323 — `opts.stackSize = epicsThreadStackBig`.
305 StackSizeClass::Big,
306 )
307 .spawn(move || {
308 run_facility_loop(
309 FACILITY,
310 || worker_loop(&pq),
311 || recover(FACILITY, pq.state.lock()).shutdown = true,
312 );
313 });
314 workers.push(handle);
315 }
316 }
317
318 CallbackPool { queues, workers }
319 }
320
321 /// A cheap, clonable submission handle (see [`CallbackHandle`]).
322 pub fn handle(&self) -> CallbackHandle {
323 CallbackHandle {
324 queues: self.queues.clone(),
325 }
326 }
327
328 /// Enqueue `cb` on `priority` — convenience wrapper over
329 /// [`CallbackHandle::request`].
330 pub fn request(&self, priority: CallbackPriority, cb: Callback) -> Result<(), CallbackError> {
331 self.queues[priority.index()].request(priority.name_prefix(), cb)
332 }
333
334 /// Lifetime overflow count for a band — C `queueOverflows`
335 /// (`callback.c:57`).
336 pub fn overflow_count(&self, priority: CallbackPriority) -> u64 {
337 recover(FACILITY, self.queues[priority.index()].state.lock()).overflows
338 }
339
340 /// Stop every band and join its workers — port of the shutdown half of
341 /// `callbackStop`/`callbackCleanup` (`callback.c:237-284`). Idempotent.
342 pub fn shutdown(&mut self) {
343 for pq in &self.queues {
344 recover(FACILITY, pq.state.lock()).shutdown = true;
345 pq.wake.notify_all();
346 }
347 for w in self.workers.drain(..) {
348 let _ = w.join();
349 }
350 }
351}
352
353impl Default for CallbackPool {
354 fn default() -> Self {
355 Self::new()
356 }
357}
358
359impl Drop for CallbackPool {
360 fn drop(&mut self) {
361 self.shutdown();
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use std::sync::atomic::{AtomicBool, Ordering};
369 use std::sync::mpsc;
370 use std::time::Duration;
371
372 const T: Duration = Duration::from_secs(5);
373
374 /// Boundary: a callback that panics. It runs on the band's own worker, so
375 /// before this one panicking callback silently retired the band and every
376 /// later callback on it — deferred processing, delayed callbacks, monitor
377 /// tails — simply never ran.
378 #[test]
379 fn a_panicking_callback_does_not_stop_the_band() {
380 let pool = CallbackPool::new();
381 pool.request(
382 CallbackPriority::Medium,
383 Box::new(|| panic!("a callback panicked on its band")),
384 )
385 .expect("enqueue the panicking callback");
386
387 let (tx, rx) = mpsc::channel();
388 pool.request(
389 CallbackPriority::Medium,
390 Box::new(move || tx.send(42u32).unwrap()),
391 )
392 .expect("enqueue the next callback");
393 assert_eq!(
394 rx.recv_timeout(T).unwrap(),
395 42,
396 "the callback after a panicking one never ran: the band worker died with it"
397 );
398 }
399
400 #[test]
401 fn enqueued_callback_runs() {
402 let pool = CallbackPool::new();
403 let (tx, rx) = mpsc::channel();
404 pool.request(
405 CallbackPriority::Medium,
406 Box::new(move || tx.send(42u32).unwrap()),
407 )
408 .unwrap();
409 assert_eq!(rx.recv_timeout(T).unwrap(), 42);
410 }
411
412 #[test]
413 fn priority_bands_are_independent() {
414 // Invariant: a blocked Low worker MUST NOT stall the High band.
415 let pool = CallbackPool::new();
416
417 let (started_tx, started_rx) = mpsc::channel();
418 let (gate_tx, gate_rx) = mpsc::channel::<()>();
419 // Occupy the single Low worker and hold it inside the callback.
420 pool.request(
421 CallbackPriority::Low,
422 Box::new(move || {
423 started_tx.send(()).unwrap();
424 gate_rx.recv().unwrap();
425 }),
426 )
427 .unwrap();
428 started_rx.recv_timeout(T).unwrap(); // Low worker is now blocked.
429
430 // High must still run despite Low being wedged.
431 let (high_tx, high_rx) = mpsc::channel();
432 pool.request(
433 CallbackPriority::High,
434 Box::new(move || high_tx.send(()).unwrap()),
435 )
436 .unwrap();
437 high_rx
438 .recv_timeout(T)
439 .expect("High band stalled behind a blocked Low worker");
440
441 gate_tx.send(()).unwrap(); // release Low so shutdown can join.
442 }
443
444 #[test]
445 fn full_ring_latches_overflow_then_recovers() {
446 // Boundary: capacity-1 ring, worker pinned busy → the second live
447 // entry fills the ring, the third latches overflow (callback.c:365).
448 let mut pool = CallbackPool::with_config(1, 1);
449 let (started_tx, started_rx) = mpsc::channel();
450 let (gate_tx, gate_rx) = mpsc::channel::<()>();
451
452 // Worker picks this up and blocks; ring is now empty again.
453 pool.request(
454 CallbackPriority::Low,
455 Box::new(move || {
456 started_tx.send(()).unwrap();
457 gate_rx.recv().unwrap();
458 }),
459 )
460 .unwrap();
461 started_rx.recv_timeout(T).unwrap();
462
463 // Fill the single ring slot (worker is busy, cannot drain).
464 pool.request(CallbackPriority::Low, Box::new(|| {}))
465 .unwrap();
466 // Next push finds the ring full → QueueFull + overflow latched.
467 assert_eq!(
468 pool.request(CallbackPriority::Low, Box::new(|| {})),
469 Err(CallbackError::QueueFull)
470 );
471 // While latched, even a would-fit push is rejected (callback.c:365).
472 assert_eq!(
473 pool.request(CallbackPriority::Low, Box::new(|| {})),
474 Err(CallbackError::QueueFull)
475 );
476 assert_eq!(pool.overflow_count(CallbackPriority::Low), 1);
477
478 gate_tx.send(()).unwrap(); // release the worker so it drains + clears.
479 pool.shutdown();
480 }
481
482 #[test]
483 fn request_after_shutdown_is_silent_noop() {
484 // Boundary: a CallbackHandle that outlives the pool (the delayed-timer
485 // teardown race) must get Ok(()) and the callback must never run.
486 let pool = CallbackPool::new();
487 let h = pool.handle();
488 drop(pool); // sets shutdown on every band, joins workers.
489
490 let ran = Arc::new(AtomicBool::new(false));
491 let r = Arc::clone(&ran);
492 let res = h.request(
493 CallbackPriority::High,
494 Box::new(move || r.store(true, Ordering::SeqCst)),
495 );
496 assert_eq!(res, Ok(())); // silent no-op, not Err.
497 assert!(
498 !ran.load(Ordering::SeqCst),
499 "callback ran after shutdown; it must be dropped, not invoked"
500 );
501 }
502}