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::{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}