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}