Skip to main content

epics_libcom_rs/runtime/background/
delayed_timer.rs

1//! Delayed-callback timer facility — RTEMS-safe port of
2//! `callbackRequestDelayed` and its `epicsTimerQueue` usage in
3//! `modules/database/src/ioc/db/callback.c`.
4//!
5//! # C parity
6//!
7//! `callbackInit` allocates one timer queue for delayed requests
8//! (`timerQueue = epicsTimerQueueAllocate(...)`, `callback.c:300`).
9//! `callbackRequestDelayed(pcallback, seconds)` (`callback.c:410-419`) starts a
10//! per-callback `epicsTimer` on that queue; when the timer expires, the queue's
11//! worker calls `notify` (`callback.c:404-408`), which simply hands the
12//! callback to `callbackRequest` — i.e. the timer's only job is to defer the
13//! enqueue by `seconds`, after which the normal callback executor runs it.
14//!
15//! The Rust port keeps that split of responsibility: **one timer thread** owns
16//! a deadline-ordered queue and, on expiry, submits the callback into the
17//! [`CallbackHandle`] executor pool. It uses `Condvar::wait_timeout` on the
18//! nearest deadline — plain `std`, no tokio timer wheel — so it runs on RTEMS.
19
20use std::collections::BTreeMap;
21use std::sync::{Arc, Condvar, Mutex};
22use std::thread::JoinHandle;
23use std::time::{Duration, Instant};
24
25use super::callback_executor::{Callback, CallbackHandle, CallbackPriority};
26use super::facility::{recover, run_facility_loop, run_isolated};
27use crate::runtime::task::{MandatoryThread, ThreadPriority};
28
29/// What this facility is called when it has to report something about itself.
30const FACILITY: &str = "delayed-callback timer";
31
32/// How a due timer entry is run when its deadline arrives.
33enum TimerAction {
34    /// Run the callback **inline on the timer thread**. For a non-blocking
35    /// wakeup only — a `sleep`/`interval` waker (`Thread::unpark` for a
36    /// `park_on` driver, a `future_exec` task re-enqueue, or a tokio
37    /// task-schedule). A wakeup needs no worker, so it does not take one: the
38    /// timer thread runs it directly rather than queueing it on a callback
39    /// band. That keeps a band's sole job "run futures" and never "wake them",
40    /// which is what stopped the sleep-wake self-deadlock a `spawn`ed future
41    /// awaiting `sleep` used to hit (`bug_pattern
42    /// rtems-exec-sleep-wake-band-deadlock`; the executor no longer parks a
43    /// worker per future, but routing wakes off the band is still the rule).
44    /// The callback must never block or do real work: it runs on the single
45    /// timer thread and delays every later deadline until it returns.
46    Inline,
47    /// Hand the callback to the executor pool at this band — C
48    /// `callbackRequestDelayed` → `callbackRequest` (`callback.c:404-419`). For
49    /// genuine deferred *work* (ODLY watchdog, SDLY reprocess) that must run on a
50    /// callback-band worker rather than the timer thread.
51    Pool(CallbackPriority),
52}
53
54/// Identifies one queued entry, and orders the queue: earliest deadline first,
55/// ties broken by submission order. Being a *key* rather than a field of the
56/// entry is what makes an entry addressable — a [`BinaryHeap`](std::collections::BinaryHeap)
57/// can only pop its top, so an entry it holds is reachable by nobody and lives
58/// until its deadline no matter who has lost interest.
59#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
60pub struct WakeKey {
61    deadline: Instant,
62    /// Tie-breaker so equal deadlines fire in submission order and the key is a
63    /// total order (the callback itself is not comparable).
64    seq: u64,
65}
66
67/// One scheduled callback awaiting its deadline. Its deadline and sequence live
68/// in the [`WakeKey`] it is filed under.
69struct TimerEntry {
70    action: TimerAction,
71    cb: Callback,
72}
73
74struct TimerState {
75    /// Deadline-ordered and *addressable*: [`Inner::cancel`] removes one entry
76    /// by key, which a heap cannot do.
77    queue: BTreeMap<WakeKey, TimerEntry>,
78    next_seq: u64,
79    shutdown: bool,
80}
81
82struct Inner {
83    state: Mutex<TimerState>,
84    wake: Condvar,
85    sink: CallbackHandle,
86}
87
88impl Inner {
89    /// Insert a scheduled callback and wake the timer thread so it can
90    /// recompute its sleep deadline. Port of `epicsTimerStartDelay`
91    /// (`callback.c:418`). Returns the key the entry is filed under, or `None`
92    /// when the timer has already shut down and the callback was dropped.
93    fn schedule(&self, delay: Duration, action: TimerAction, cb: Callback) -> Option<WakeKey> {
94        let deadline = Instant::now() + delay;
95        let mut st = recover(FACILITY, self.state.lock());
96        if st.shutdown {
97            // Timer thread stopped: match C dropping late delayed requests
98            // during shutdown. Drop `cb` (never scheduled or fired) instead of
99            // filing it in a queue no worker will ever drain.
100            drop(st);
101            tracing::trace!(
102                target: "epics_base_rs::runtime::delayed_timer",
103                "callbackRequestDelayed after shutdown dropped"
104            );
105            return None;
106        }
107        let key = WakeKey {
108            deadline,
109            seq: st.next_seq,
110        };
111        st.next_seq += 1;
112        st.queue.insert(key, TimerEntry { action, cb });
113        drop(st);
114        self.wake.notify_one();
115        Some(key)
116    }
117
118    /// Remove the entry filed under `key`, dropping its callback now rather
119    /// than at its deadline. A key whose entry has already fired is a no-op.
120    fn cancel(&self, key: WakeKey) {
121        // Taken out of the lock before it drops: a callback's drop glue is
122        // arbitrary user code and must never run while the queue is held.
123        let entry = {
124            let mut st = recover(FACILITY, self.state.lock());
125            st.queue.remove(&key)
126        };
127        drop(entry);
128    }
129}
130
131/// Port of the timer-queue worker: sleep until the nearest deadline, then hand
132/// every due callback to the executor pool via `notify`
133/// (`callback.c:404-408`).
134fn timer_loop(inner: &Inner) {
135    let mut st = recover(FACILITY, inner.state.lock());
136    loop {
137        if st.shutdown {
138            return;
139        }
140        let now = Instant::now();
141        // `deadline` is `Copy`, so this releases the borrow immediately.
142        match st.queue.first_key_value().map(|(k, _)| k.deadline) {
143            Some(deadline) if deadline <= now => {
144                // Due: run it. A wakeup (`Inline`) runs here on the timer thread
145                // — it only unparks a driver, needs no worker, and must not queue
146                // on the callback pool (that is the sleep-wake self-deadlock). A
147                // deferred-work callback (`Pool`) is handed to the executor pool.
148                let (_, entry) = st.queue.pop_first().unwrap();
149                drop(st);
150                match entry.action {
151                    TimerAction::Inline => {
152                        run_isolated(FACILITY, entry.cb);
153                    }
154                    TimerAction::Pool(priority) => {
155                        let _ = inner.sink.request(priority, entry.cb);
156                    }
157                }
158                st = recover(FACILITY, inner.state.lock());
159            }
160            Some(deadline) => {
161                // Not yet due: sleep until it is, or until a nearer request
162                // wakes us.
163                let wait = deadline.saturating_duration_since(now);
164                let (guard, _timeout) = recover(FACILITY, inner.wake.wait_timeout(st, wait));
165                st = guard;
166            }
167            None => {
168                // Nothing scheduled: sleep until a request or shutdown.
169                st = recover(FACILITY, inner.wake.wait(st));
170            }
171        }
172    }
173}
174
175/// Cheap, clonable submission side of a [`DelayedTimer`] — the seam route for
176/// deferred (SDLY/ODLY/watchdog-style) hand-offs.
177#[derive(Clone)]
178pub struct TimerHandle {
179    inner: Arc<Inner>,
180}
181
182impl TimerHandle {
183    /// Schedule deferred *work* `cb` to be enqueued on `priority` after `delay`.
184    /// Returns immediately — the callback runs on a pool worker no earlier than
185    /// `delay` from now. Port of `callbackRequestDelayed` (`callback.c:410`).
186    pub fn schedule(&self, delay: Duration, priority: CallbackPriority, cb: Callback) {
187        self.inner.schedule(delay, TimerAction::Pool(priority), cb);
188    }
189
190    /// Schedule a **non-blocking wakeup** `cb` to run inline on the timer thread
191    /// after `delay` — the `sleep`/`interval` waker path. `cb` MUST be trivial
192    /// and non-blocking (only a waker `wake()`: an `unpark` or a tokio
193    /// task-schedule); it runs on the single timer thread and delays every later
194    /// deadline until it returns.
195    ///
196    /// This exists so a `spawn`ed future that awaits `sleep` is never blocked by
197    /// its own wake: running it on the timer thread frees the band from the dual
198    /// role of "run futures" AND "wake them". It is what closed the sleep-wake
199    /// self-deadlock (`bug_pattern rtems-exec-sleep-wake-band-deadlock`), back
200    /// when `future_exec` parked a worker per future for the future's whole
201    /// life; that executor is now cooperative and holds a worker only across a
202    /// single poll, but a wake still costs no worker here.
203    ///
204    /// The returned [`WakeKey`] is the caller's claim on the queued entry, and
205    /// the caller MUST pass it to [`cancel_wake`](Self::cancel_wake) when it
206    /// stops caring about the wakeup. `None` means the timer had already shut
207    /// down and `cb` was dropped unscheduled — there is nothing to cancel.
208    #[must_use = "a wake entry lives until its deadline unless its key is cancelled"]
209    pub fn schedule_wake(&self, delay: Duration, cb: Callback) -> Option<WakeKey> {
210        self.inner.schedule(delay, TimerAction::Inline, cb)
211    }
212
213    /// Drop the wake entry filed under `key` now, instead of leaving it queued
214    /// until its deadline. Cancelling an entry that has already fired is a
215    /// no-op, so a caller never has to know which happened first.
216    ///
217    /// Unlike [`schedule`](Self::schedule), which is C's fire-and-forget
218    /// `callbackRequestDelayed`, a wake belongs to the sleeper that armed it:
219    /// the entry holds a clone of the sleeper's shared cell, so an uncancelled
220    /// entry keeps that cell — and the OS mutex inside it — alive for the whole
221    /// remaining delay even though the sleeper is long gone.
222    pub fn cancel_wake(&self, key: WakeKey) {
223        self.inner.cancel(key);
224    }
225
226    /// How many entries are queued. For tests and on-target probes: the
227    /// per-sleep retention this queue used to carry is only visible as a count.
228    pub fn scheduled_count(&self) -> usize {
229        recover(FACILITY, self.inner.state.lock()).queue.len()
230    }
231}
232
233/// The delayed-callback timer: one thread draining a deadline-ordered queue
234/// into the callback executor pool. Port of the `timerQueue` half of
235/// `callback.c`.
236///
237/// Dropping the timer stops and joins its thread.
238pub struct DelayedTimer {
239    inner: Arc<Inner>,
240    worker: Option<JoinHandle<()>>,
241}
242
243impl DelayedTimer {
244    /// Create a timer that fires callbacks into `sink` (the callback executor
245    /// pool). Mirrors `callbackInit`'s single `epicsTimerQueueAllocate`
246    /// (`callback.c:300`) whose `notify` routes to `callbackRequest`.
247    pub fn new(sink: CallbackHandle) -> Self {
248        let inner = Arc::new(Inner {
249            state: Mutex::new(TimerState {
250                queue: BTreeMap::new(),
251                next_seq: 0,
252                shutdown: false,
253            }),
254            wake: Condvar::new(),
255            sink,
256        });
257        let worker_inner = Arc::clone(&inner);
258        // Every timed facility in this runtime — `sleep`, `interval`, scan
259        // periods, `callbackRequestDelayed` — funnels through the loop below,
260        // on this one thread. Losing it stops all of them at once while the IOC
261        // goes on serving, so its loss is the one that must never be inferred
262        // from work that quietly stops happening — neither after start-up
263        // (`run_facility_loop`) nor at start-up (`MandatoryThread`).
264        let worker = MandatoryThread::new(
265            "cbTimer",
266            // callback.c:300 — the delayed-callback queue is allocated with
267            // `epicsTimerQueueAllocate(0, epicsThreadPriorityScanHigh)`. That
268            // puts the timer above the Low (59) and Medium (64) bands it feeds
269            // but just below High (71), matching C: a due deadline preempts
270            // ordinary callback work, and a High callback still preempts the
271            // timer. Best effort, and only when the RT switch is on
272            // (`runtime::task::RT_PRIORITY_ENV`).
273            ThreadPriority::ScanHigh,
274            // C allocates the timer queue's thread with
275            // `epicsThreadGetStackSize(epicsThreadStackMedium)`
276            // (`libcom/src/timer/timerQueueActive.cpp:48`). This thread only
277            // fires expirations onto the callback bands; the arbitrary work
278            // runs on those, which are Big.
279            crate::runtime::task::StackSizeClass::Medium,
280        )
281        .spawn(move || {
282            run_facility_loop(
283                FACILITY,
284                || timer_loop(&worker_inner),
285                || recover(FACILITY, worker_inner.state.lock()).shutdown = true,
286            );
287        });
288        DelayedTimer {
289            inner,
290            worker: Some(worker),
291        }
292    }
293
294    /// A cheap, clonable scheduling handle (see [`TimerHandle`]).
295    pub fn handle(&self) -> TimerHandle {
296        TimerHandle {
297            inner: Arc::clone(&self.inner),
298        }
299    }
300
301    /// Schedule `cb` after `delay` — convenience wrapper over
302    /// [`TimerHandle::schedule`] (pool-dispatched deferred work).
303    pub fn schedule(&self, delay: Duration, priority: CallbackPriority, cb: Callback) {
304        self.inner.schedule(delay, TimerAction::Pool(priority), cb);
305    }
306}
307
308impl Drop for DelayedTimer {
309    fn drop(&mut self) {
310        {
311            let mut st = recover(FACILITY, self.inner.state.lock());
312            st.shutdown = true;
313        }
314        self.inner.wake.notify_all();
315        if let Some(w) = self.worker.take() {
316            let _ = w.join();
317        }
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::runtime::background::callback_executor::CallbackPool;
325    use std::sync::mpsc;
326
327    const T: Duration = Duration::from_secs(5);
328
329    #[test]
330    fn delayed_callback_fires_no_earlier_than_delay() {
331        let pool = CallbackPool::new();
332        let timer = DelayedTimer::new(pool.handle());
333
334        let delay = Duration::from_millis(80);
335        let (tx, rx) = mpsc::channel();
336        let start = Instant::now();
337        timer.schedule(
338            delay,
339            CallbackPriority::High,
340            Box::new(move || tx.send(Instant::now()).unwrap()),
341        );
342
343        let fired_at = rx.recv_timeout(T).unwrap();
344        assert!(
345            fired_at.duration_since(start) >= delay,
346            "callback fired after {:?}, earlier than the {:?} delay",
347            fired_at.duration_since(start),
348            delay
349        );
350    }
351
352    #[test]
353    fn earlier_deadline_fires_before_later_one() {
354        // Invariant: the queue is deadline-ordered, not insertion-ordered.
355        let pool = CallbackPool::new();
356        let timer = DelayedTimer::new(pool.handle());
357        let (tx, rx) = mpsc::channel();
358
359        // Schedule the *longer* delay first, then a shorter one.
360        let tx_long = tx.clone();
361        timer.schedule(
362            Duration::from_millis(150),
363            CallbackPriority::Medium,
364            Box::new(move || tx_long.send("long").unwrap()),
365        );
366        timer.schedule(
367            Duration::from_millis(30),
368            CallbackPriority::Medium,
369            Box::new(move || tx.send("short").unwrap()),
370        );
371
372        assert_eq!(rx.recv_timeout(T).unwrap(), "short");
373        assert_eq!(rx.recv_timeout(T).unwrap(), "long");
374    }
375
376    /// Boundary: a wake that panics. It runs inline on the timer thread, so
377    /// before this it took every later deadline in the IOC with it — sleep,
378    /// interval, scan periods, `callbackRequestDelayed` — and said nothing.
379    #[test]
380    fn a_panicking_wake_does_not_stop_the_timer() {
381        let pool = CallbackPool::new();
382        let timer = DelayedTimer::new(pool.handle());
383        let h = timer.handle();
384
385        let _key = h.schedule_wake(
386            Duration::from_millis(10),
387            Box::new(|| panic!("a waker panicked on the timer thread")),
388        );
389
390        let (tx, rx) = mpsc::channel();
391        timer.schedule(
392            Duration::from_millis(40),
393            CallbackPriority::High,
394            Box::new(move || tx.send(()).unwrap()),
395        );
396
397        assert_eq!(
398            rx.recv_timeout(T),
399            Ok(()),
400            "the deadline after a panicking wake never fired: the timer thread died with it"
401        );
402    }
403
404    /// Boundary: the state mutex is poisoned. Every scheduling path in this
405    /// file took it with `.unwrap()`, so one panic anywhere under the lock
406    /// stopped all timed work.
407    #[test]
408    fn a_poisoned_state_still_schedules_and_fires() {
409        let pool = CallbackPool::new();
410        let timer = DelayedTimer::new(pool.handle());
411
412        let inner = Arc::clone(&timer.inner);
413        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
414            let _held = inner.state.lock().expect("first lock");
415            panic!("poison the timer state");
416        }));
417        assert!(
418            timer.inner.state.lock().is_err(),
419            "the state mutex must actually be poisoned for this to test anything"
420        );
421
422        let (tx, rx) = mpsc::channel();
423        timer.schedule(
424            Duration::from_millis(10),
425            CallbackPriority::High,
426            Box::new(move || tx.send(()).unwrap()),
427        );
428        assert_eq!(
429            rx.recv_timeout(T),
430            Ok(()),
431            "a poisoned state stopped the timer facility"
432        );
433    }
434
435    /// Boundary: a wake whose owner loses interest before the deadline. The
436    /// queue used to be a `BinaryHeap`, which can only pop its top, so an entry
437    /// it held was reachable by nobody and stayed until its deadline — with
438    /// everything the callback captured.
439    #[test]
440    fn cancelling_a_wake_drops_its_entry_before_the_deadline() {
441        let pool = CallbackPool::new();
442        let timer = DelayedTimer::new(pool.handle());
443        let h = timer.handle();
444
445        // An hour out: nothing but cancellation can retire this.
446        let key = h
447            .schedule_wake(Duration::from_secs(3600), Box::new(|| ()))
448            .expect("a live timer must queue the wake");
449        assert_eq!(h.scheduled_count(), 1, "the wake was not queued");
450
451        h.cancel_wake(key);
452        assert_eq!(
453            h.scheduled_count(),
454            0,
455            "the cancelled wake is still queued and will hold its callback for an hour"
456        );
457    }
458
459    /// Cancelling twice, or cancelling a wake that already fired, must be a
460    /// no-op — the owner cannot know which happened first, so it always calls.
461    #[test]
462    fn cancelling_an_already_gone_wake_is_a_no_op() {
463        let pool = CallbackPool::new();
464        let timer = DelayedTimer::new(pool.handle());
465        let h = timer.handle();
466
467        let key = h
468            .schedule_wake(Duration::from_secs(3600), Box::new(|| ()))
469            .expect("a live timer must queue the wake");
470        h.cancel_wake(key);
471        h.cancel_wake(key);
472        assert_eq!(h.scheduled_count(), 0);
473
474        // And one that fired on its own.
475        let (tx, rx) = mpsc::channel();
476        let fired = h
477            .schedule_wake(
478                Duration::from_millis(10),
479                Box::new(move || tx.send(()).unwrap()),
480            )
481            .expect("a live timer must queue the wake");
482        assert_eq!(rx.recv_timeout(T), Ok(()));
483        h.cancel_wake(fired);
484        assert_eq!(h.scheduled_count(), 0);
485    }
486
487    /// The ordering the key encodes is the ordering the queue had: earliest
488    /// deadline first, ties in submission order. `BinaryHeap` got that from a
489    /// reversed `Ord` on the entry; the map gets it from the key's natural one.
490    #[test]
491    fn equal_deadlines_fire_in_submission_order() {
492        let pool = CallbackPool::new();
493        let timer = DelayedTimer::new(pool.handle());
494        let (tx, rx) = mpsc::channel();
495
496        for n in 0..4 {
497            let tx = tx.clone();
498            timer.schedule(
499                Duration::from_millis(20),
500                CallbackPriority::Medium,
501                Box::new(move || tx.send(n).unwrap()),
502            );
503        }
504        let got: Vec<i32> = (0..4).map(|_| rx.recv_timeout(T).unwrap()).collect();
505        assert_eq!(got, vec![0, 1, 2, 3]);
506    }
507
508    #[test]
509    fn schedule_after_shutdown_never_fires() {
510        // Boundary: a TimerHandle that outlives the timer must drop late
511        // requests silently — the callback must never reach the sink pool.
512        let pool = CallbackPool::new();
513        let timer = DelayedTimer::new(pool.handle());
514        let h = timer.handle();
515        drop(timer); // sets shutdown, joins the timer thread.
516
517        let (tx, rx) = mpsc::channel::<()>();
518        h.schedule(
519            Duration::from_millis(0),
520            CallbackPriority::High,
521            Box::new(move || tx.send(()).unwrap()),
522        );
523        // The callback (and its `tx`) is dropped synchronously inside
524        // schedule()'s shutdown branch — never scheduled, never fired. The
525        // receiver therefore sees the sender gone (Disconnected), and never a
526        // delivered `()`.
527        assert_eq!(
528            rx.recv_timeout(Duration::from_millis(200)),
529            Err(mpsc::RecvTimeoutError::Disconnected),
530            "a delayed callback fired after shutdown; it must be dropped"
531        );
532    }
533}