Skip to main content

epics_libcom_rs/runtime/background/
scan_once.rs

1//! `scanOnce` queue + worker — RTEMS-safe port of the `onceQ`/`scanOnce`/
2//! `onceTask` machinery in `modules/database/src/ioc/db/dbScan.c`.
3//!
4//! # C parity
5//!
6//! C keeps a single bounded ring `onceQ` of `onceQueueSize == 1000` entries
7//! (`dbScan.c:65-67`) drained by one dedicated `scanOnce` worker thread
8//! (`dbScan.c:69`, `onceTaskId`). `scanOnce(prec)` / `scanOnceCallback`
9//! (`dbScan.c:664-698`) pushes an entry and **returns immediately**
10//! (`return !pushOK`, `dbScan.c:697`); the caller never blocks on record
11//! processing. `onceTask` (`dbScan.c:700-730`) waits on `onceSem`, drains the
12//! ring, and for each entry does `dbScanLock` / `dbProcess` / `dbScanUnlock`
13//! (`dbScan.c:719-721`) plus an optional completion callback
14//! (`dbScan.c:722-723`).
15//!
16//! The Rust port keeps that exact shape with **plain `std` threads +
17//! `Mutex`/`Condvar`** and a boxed closure per entry (the closure carries the
18//! "lock + process this record" tail the seam supplies later — this increment
19//! does not touch `pv.rs`/`processing.rs`). No tokio-runtime dependency, so it
20//! runs on RTEMS.
21//!
22//! ## Overflow hysteresis (`dbScan.c:676`, `:687-694`)
23//!
24//! A `static int newOverflow` latch makes C print the
25//! `"scanOnce: Ring buffer overflow"` warning **once per overflow episode**:
26//! the first full push logs and clears `newOverflow`; further full pushes only
27//! bump `onceQOverruns` silently; the next *successful* push re-arms the latch.
28//! We reproduce that latch exactly.
29
30use std::collections::VecDeque;
31use std::sync::{Arc, Condvar, Mutex};
32use std::thread::JoinHandle;
33
34use super::facility::{recover, run_facility_loop, run_isolated};
35use crate::runtime::task::{MandatoryThread, StackSizeClass, ThreadPriority};
36
37/// A queued "process this record" tail. C stores `{prec, cb, usr}`
38/// (`dbScan.c:668-672`); the Rust port boxes a closure that already captures
39/// the record handle and completion callback.
40pub type OnceCallback = Box<dyn FnOnce() + Send + 'static>;
41
42/// Default ring capacity — C `onceQueueSize` (`dbScan.c:65`).
43pub const DEFAULT_ONCE_QUEUE_SIZE: usize = 1000;
44
45/// The `scanOnce` ring was full — C returns non-zero (`!pushOK`,
46/// `dbScan.c:697`) and drops the request.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct ScanOnceOverflow;
49
50struct OnceState {
51    queue: VecDeque<OnceCallback>,
52    /// C `onceQOverruns` — lifetime overflow count (`dbScan.c:68`).
53    overflows: u64,
54    /// C `static int newOverflow` latch (`dbScan.c:676`): `true` means the
55    /// next overflow should log.
56    new_overflow: bool,
57    shutdown: bool,
58}
59
60struct Inner {
61    capacity: usize,
62    state: Mutex<OnceState>,
63    /// C `onceSem` (`dbScan.c`), the worker's wake-up event.
64    wake: Condvar,
65}
66
67impl Inner {
68    /// Port of `scanOnceCallback` (`dbScan.c:674-698`).
69    fn scan_once(&self, cb: OnceCallback) -> Result<(), ScanOnceOverflow> {
70        let mut st = recover(FACILITY, self.state.lock());
71        if st.shutdown {
72            // Worker stopped: C drops late scanOnce requests during shutdown
73            // without surfacing an error (parity with `callbackStop` handling
74            // of late requests). Drop `cb` (never processed) and report
75            // success rather than a spurious overflow.
76            drop(st);
77            tracing::trace!(
78                target: "epics_base_rs::runtime::scan_once",
79                "scanOnce after shutdown dropped"
80            );
81            return Ok(());
82        }
83        let result = if st.queue.len() >= self.capacity {
84            // dbScan.c:686-691 — ring full: log once per episode, then count.
85            if st.new_overflow {
86                tracing::warn!(
87                    target: "epics_base_rs::runtime::scan_once",
88                    "WARNING scanOnce: Ring buffer overflow"
89                );
90            }
91            st.new_overflow = false; // dbScan.c:690
92            st.overflows += 1; // dbScan.c:691
93            Err(ScanOnceOverflow)
94        } else {
95            st.new_overflow = true; // dbScan.c:693 — re-arm on a good push.
96            st.queue.push_back(cb);
97            Ok(())
98        };
99        drop(st);
100        // dbScan.c:695 — `epicsEventSignal(onceSem)` is issued unconditionally,
101        // outside the push success/failure branch.
102        self.wake.notify_one();
103        result
104    }
105}
106
107/// What this facility is called when it has to report something about itself.
108const FACILITY: &str = "scanOnce worker";
109
110/// How many periodic scan rates the record system defines — C's `nPeriodic`,
111/// sized from menuScan at `scanInit` (`dbScan.c`).
112///
113/// Stated here rather than read from the record system because this crate is
114/// *below* it: `epics-base-rs` owns `server::scan::PERIODIC_SCANS`, and a
115/// dependency edge back up would be a cycle. The two are pinned together in
116/// the direction that has one — `epics-base-rs`'s `server::scan` carries a
117/// `const _: () = assert!(PERIODIC_SCANS.len() == PERIODIC_SCAN_BAND_COUNT)`,
118/// so adding or removing a rate fails to compile rather than silently moving
119/// one side of the band ladder.
120pub const PERIODIC_SCAN_BAND_COUNT: usize = 7;
121
122/// The scanOnce worker's EPICS band — `epicsThreadPriorityScanLow +
123/// nPeriodic` (`dbScan.c:776`), where `nPeriodic` is the number of periodic
124/// scan rates ([`PERIODIC_SCAN_BAND_COUNT`], pinned to
125/// `server::scan::PERIODIC_SCANS.len()` so the two cannot drift apart).
126/// 60 + 7 = 67: scanOnce preempts every periodic scan thread, as in C.
127fn scan_once_priority() -> ThreadPriority {
128    ThreadPriority::Custom(ThreadPriority::ScanLow.value() + PERIODIC_SCAN_BAND_COUNT as u8)
129}
130
131/// Port of `onceTask` (`dbScan.c:700-730`): wait on the wake event, drain the
132/// ring, run each queued tail.
133fn once_loop(inner: &Inner) {
134    loop {
135        let mut st = recover(FACILITY, inner.state.lock());
136        while st.queue.is_empty() && !st.shutdown {
137            st = recover(FACILITY, inner.wake.wait(st));
138        }
139        if st.queue.is_empty() {
140            return; // empty + shutdown
141        }
142        let cb = st.queue.pop_front().unwrap();
143        drop(st);
144        // dbScan.c:719-723 — the queued tail owns lock/dbProcess/unlock/cb.
145        run_isolated(FACILITY, cb);
146    }
147}
148
149/// Cheap, clonable submission side of a [`ScanOnceQueue`] — the seam route the
150/// FLNK/scanOnce chain hands records into.
151#[derive(Clone)]
152pub struct ScanOnceHandle {
153    inner: Arc<Inner>,
154}
155
156impl ScanOnceHandle {
157    /// Enqueue `cb` for one-shot processing and return immediately. `Err` on a
158    /// full ring (the request is dropped, as in C). Port of `scanOnce`
159    /// (`dbScan.c:664`).
160    pub fn scan_once(&self, cb: OnceCallback) -> Result<(), ScanOnceOverflow> {
161        self.inner.scan_once(cb)
162    }
163
164    /// Lifetime overflow count — C `onceQOverruns` (`dbScan.c:68`).
165    pub fn overflow_count(&self) -> u64 {
166        recover(FACILITY, self.inner.state.lock()).overflows
167    }
168}
169
170/// The `scanOnce` facility: one bounded ring drained by one worker thread.
171///
172/// Dropping it stops and joins the worker.
173pub struct ScanOnceQueue {
174    inner: Arc<Inner>,
175    worker: Option<JoinHandle<()>>,
176}
177
178impl ScanOnceQueue {
179    /// Build with the C default capacity (`onceQueueSize`, `dbScan.c:65`).
180    pub fn new() -> Self {
181        Self::with_capacity(DEFAULT_ONCE_QUEUE_SIZE)
182    }
183
184    /// Build with an explicit ring capacity (clamped to at least 1).
185    pub fn with_capacity(capacity: usize) -> Self {
186        let inner = Arc::new(Inner {
187            capacity: capacity.max(1),
188            state: Mutex::new(OnceState {
189                queue: VecDeque::new(),
190                overflows: 0,
191                new_overflow: true,
192                shutdown: false,
193            }),
194            wake: Condvar::new(),
195        });
196        let worker_inner = Arc::clone(&inner);
197        // Losing this thread stops every FLNK/scanOnce tail in the IOC while
198        // records still accept writes — the same silent half-IOC whether the
199        // loss happens later (`run_facility_loop`) or at creation
200        // (`MandatoryThread`).
201        let worker = MandatoryThread::new(
202            // dbScan.c:783 — thread name "scanOnce".
203            "scanOnce",
204            // dbScan.c:776 — priority `epicsThreadPriorityScanLow + nPeriodic`.
205            // `nPeriodic` is the number of periodic scan rates (menuScan's
206            // seven periodic choices; `server::scan::PERIODIC_SCANS` here),
207            // so scanOnce preempts every periodic scan thread: 60 + 7 = 67.
208            // Measured on the C IOC on RTEMS 6: scanOnce OSIPRI 67
209            // (doc/upstream-rtems-bugs/measurement-c-thread-priority-on-rtems-6.md).
210            scan_once_priority(),
211            // dbScan.c:777 — `opts.stackSize = epicsThreadStackBig`.
212            StackSizeClass::Big,
213        )
214        .spawn(move || {
215            run_facility_loop(
216                FACILITY,
217                || once_loop(&worker_inner),
218                || recover(FACILITY, worker_inner.state.lock()).shutdown = true,
219            );
220        });
221        ScanOnceQueue {
222            inner,
223            worker: Some(worker),
224        }
225    }
226
227    /// A cheap, clonable submission handle (see [`ScanOnceHandle`]).
228    pub fn handle(&self) -> ScanOnceHandle {
229        ScanOnceHandle {
230            inner: Arc::clone(&self.inner),
231        }
232    }
233
234    /// Enqueue `cb` for one-shot processing — convenience wrapper over
235    /// [`ScanOnceHandle::scan_once`].
236    pub fn scan_once(&self, cb: OnceCallback) -> Result<(), ScanOnceOverflow> {
237        self.inner.scan_once(cb)
238    }
239
240    /// Lifetime overflow count — C `onceQOverruns` (`dbScan.c:68`).
241    pub fn overflow_count(&self) -> u64 {
242        recover(FACILITY, self.inner.state.lock()).overflows
243    }
244}
245
246impl Default for ScanOnceQueue {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252impl Drop for ScanOnceQueue {
253    fn drop(&mut self) {
254        {
255            let mut st = recover(FACILITY, self.inner.state.lock());
256            st.shutdown = true;
257        }
258        self.inner.wake.notify_all();
259        if let Some(w) = self.worker.take() {
260            let _ = w.join();
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use std::sync::atomic::{AtomicBool, Ordering};
269    use std::sync::mpsc;
270    use std::time::Duration;
271
272    const T: Duration = Duration::from_secs(5);
273
274    /// `dbScan.c:776` — scanOnce runs at `ScanLow + nPeriodic`, above every
275    /// periodic scan thread. Measured on the C IOC on RTEMS 6 as OSIPRI 67;
276    /// this pins ours to the same number and to the periodic-rate count, so
277    /// adding or removing a rate moves both sides together.
278    #[test]
279    fn scan_once_band_is_scanlow_plus_n_periodic() {
280        assert_eq!(
281            scan_once_priority().value(),
282            ThreadPriority::ScanLow.value() + PERIODIC_SCAN_BAND_COUNT as u8
283        );
284        assert_eq!(scan_once_priority().value(), 67);
285    }
286
287    /// Boundary: a queued tail that panics. `dbProcess` runs inside it, so
288    /// before this one bad record stopped every FLNK and every `scanOnce` in
289    /// the IOC, with nothing said.
290    #[test]
291    fn a_panicking_tail_does_not_stop_the_worker() {
292        let q = ScanOnceQueue::new();
293        q.scan_once(Box::new(|| panic!("a scanOnce tail panicked")))
294            .expect("enqueue the panicking tail");
295
296        let (tx, rx) = mpsc::channel();
297        q.scan_once(Box::new(move || tx.send(7u32).unwrap()))
298            .expect("enqueue the next tail");
299        assert_eq!(
300            rx.recv_timeout(T).unwrap(),
301            7,
302            "the tail after a panicking one never ran: the worker died with it"
303        );
304    }
305
306    #[test]
307    fn enqueue_returns_immediately_and_worker_drains() {
308        let q = ScanOnceQueue::new();
309        let (tx, rx) = mpsc::channel();
310        // Non-blocking by construction: this returns before the worker runs.
311        q.scan_once(Box::new(move || tx.send(7u32).unwrap()))
312            .unwrap();
313        assert_eq!(rx.recv_timeout(T).unwrap(), 7);
314    }
315
316    #[test]
317    fn overflow_latches_and_counts() {
318        // Boundary: capacity-1 ring, worker pinned busy → second live entry
319        // fills the ring, third overflows (dbScan.c:686).
320        let q = ScanOnceQueue::with_capacity(1);
321        let (started_tx, started_rx) = mpsc::channel();
322        let (gate_tx, gate_rx) = mpsc::channel::<()>();
323
324        // Worker picks this up and blocks inside it; ring is empty again.
325        q.scan_once(Box::new(move || {
326            started_tx.send(()).unwrap();
327            gate_rx.recv().unwrap();
328        }))
329        .unwrap();
330        started_rx.recv_timeout(T).unwrap();
331
332        // Fill the single ring slot (worker is busy).
333        q.scan_once(Box::new(|| {})).unwrap();
334        // Ring full → overflow, request dropped.
335        assert_eq!(q.scan_once(Box::new(|| {})), Err(ScanOnceOverflow));
336        assert_eq!(q.scan_once(Box::new(|| {})), Err(ScanOnceOverflow));
337        assert_eq!(q.overflow_count(), 2);
338
339        gate_tx.send(()).unwrap(); // release the worker so it can drain.
340    }
341
342    #[test]
343    fn scan_once_after_shutdown_is_silent_noop() {
344        // Boundary: a ScanOnceHandle that outlives the queue must get Ok(())
345        // and the tail must never run — not a spurious overflow error.
346        let q = ScanOnceQueue::new();
347        let h = q.handle();
348        drop(q); // sets shutdown, joins the worker.
349
350        let ran = Arc::new(AtomicBool::new(false));
351        let r = Arc::clone(&ran);
352        let res = h.scan_once(Box::new(move || r.store(true, Ordering::SeqCst)));
353        assert_eq!(res, Ok(())); // silent no-op, not Err(ScanOnceOverflow).
354        assert!(
355            !ran.load(Ordering::SeqCst),
356            "scanOnce tail ran after shutdown; it must be dropped, not processed"
357        );
358        assert_eq!(h.overflow_count(), 0); // a shutdown drop is not an overflow.
359    }
360}