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}