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 = crate::runtime::time::deadline_from_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 /// `callbackRequestDelayed` with a delay past `Duration`'s range must
330 /// file a deadline that never arrives, not unwind the caller.
331 /// `duration_from_secs` maps `+inf`, `NaN` and `1e300` to
332 /// `Duration::MAX` because a record delay field is network-settable,
333 /// and `Instant + Duration::MAX` panics.
334 #[test]
335 fn an_unrepresentable_delay_schedules_instead_of_panicking() {
336 let pool = CallbackPool::new();
337 let timer = DelayedTimer::new(pool.handle());
338 timer.schedule(Duration::MAX, CallbackPriority::High, Box::new(|| {}));
339 assert_eq!(timer.handle().scheduled_count(), 1);
340 }
341
342 #[test]
343 fn delayed_callback_fires_no_earlier_than_delay() {
344 let pool = CallbackPool::new();
345 let timer = DelayedTimer::new(pool.handle());
346
347 let delay = Duration::from_millis(80);
348 let (tx, rx) = mpsc::channel();
349 let start = Instant::now();
350 timer.schedule(
351 delay,
352 CallbackPriority::High,
353 Box::new(move || tx.send(Instant::now()).unwrap()),
354 );
355
356 let fired_at = rx.recv_timeout(T).unwrap();
357 assert!(
358 fired_at.duration_since(start) >= delay,
359 "callback fired after {:?}, earlier than the {:?} delay",
360 fired_at.duration_since(start),
361 delay
362 );
363 }
364
365 #[test]
366 fn earlier_deadline_fires_before_later_one() {
367 // Invariant: the queue is deadline-ordered, not insertion-ordered.
368 let pool = CallbackPool::new();
369 let timer = DelayedTimer::new(pool.handle());
370 let (tx, rx) = mpsc::channel();
371
372 // Schedule the *longer* delay first, then a shorter one.
373 let tx_long = tx.clone();
374 timer.schedule(
375 Duration::from_millis(150),
376 CallbackPriority::Medium,
377 Box::new(move || tx_long.send("long").unwrap()),
378 );
379 timer.schedule(
380 Duration::from_millis(30),
381 CallbackPriority::Medium,
382 Box::new(move || tx.send("short").unwrap()),
383 );
384
385 assert_eq!(rx.recv_timeout(T).unwrap(), "short");
386 assert_eq!(rx.recv_timeout(T).unwrap(), "long");
387 }
388
389 /// Boundary: a wake that panics. It runs inline on the timer thread, so
390 /// before this it took every later deadline in the IOC with it — sleep,
391 /// interval, scan periods, `callbackRequestDelayed` — and said nothing.
392 #[test]
393 fn a_panicking_wake_does_not_stop_the_timer() {
394 let pool = CallbackPool::new();
395 let timer = DelayedTimer::new(pool.handle());
396 let h = timer.handle();
397
398 let _key = h.schedule_wake(
399 Duration::from_millis(10),
400 Box::new(|| panic!("a waker panicked on the timer thread")),
401 );
402
403 let (tx, rx) = mpsc::channel();
404 timer.schedule(
405 Duration::from_millis(40),
406 CallbackPriority::High,
407 Box::new(move || tx.send(()).unwrap()),
408 );
409
410 assert_eq!(
411 rx.recv_timeout(T),
412 Ok(()),
413 "the deadline after a panicking wake never fired: the timer thread died with it"
414 );
415 }
416
417 /// Boundary: the state mutex is poisoned. Every scheduling path in this
418 /// file took it with `.unwrap()`, so one panic anywhere under the lock
419 /// stopped all timed work.
420 #[test]
421 fn a_poisoned_state_still_schedules_and_fires() {
422 let pool = CallbackPool::new();
423 let timer = DelayedTimer::new(pool.handle());
424
425 let inner = Arc::clone(&timer.inner);
426 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
427 let _held = inner.state.lock().expect("first lock");
428 panic!("poison the timer state");
429 }));
430 assert!(
431 timer.inner.state.lock().is_err(),
432 "the state mutex must actually be poisoned for this to test anything"
433 );
434
435 let (tx, rx) = mpsc::channel();
436 timer.schedule(
437 Duration::from_millis(10),
438 CallbackPriority::High,
439 Box::new(move || tx.send(()).unwrap()),
440 );
441 assert_eq!(
442 rx.recv_timeout(T),
443 Ok(()),
444 "a poisoned state stopped the timer facility"
445 );
446 }
447
448 /// Boundary: a wake whose owner loses interest before the deadline. The
449 /// queue used to be a `BinaryHeap`, which can only pop its top, so an entry
450 /// it held was reachable by nobody and stayed until its deadline — with
451 /// everything the callback captured.
452 #[test]
453 fn cancelling_a_wake_drops_its_entry_before_the_deadline() {
454 let pool = CallbackPool::new();
455 let timer = DelayedTimer::new(pool.handle());
456 let h = timer.handle();
457
458 // An hour out: nothing but cancellation can retire this.
459 let key = h
460 .schedule_wake(Duration::from_secs(3600), Box::new(|| ()))
461 .expect("a live timer must queue the wake");
462 assert_eq!(h.scheduled_count(), 1, "the wake was not queued");
463
464 h.cancel_wake(key);
465 assert_eq!(
466 h.scheduled_count(),
467 0,
468 "the cancelled wake is still queued and will hold its callback for an hour"
469 );
470 }
471
472 /// Cancelling twice, or cancelling a wake that already fired, must be a
473 /// no-op — the owner cannot know which happened first, so it always calls.
474 #[test]
475 fn cancelling_an_already_gone_wake_is_a_no_op() {
476 let pool = CallbackPool::new();
477 let timer = DelayedTimer::new(pool.handle());
478 let h = timer.handle();
479
480 let key = h
481 .schedule_wake(Duration::from_secs(3600), Box::new(|| ()))
482 .expect("a live timer must queue the wake");
483 h.cancel_wake(key);
484 h.cancel_wake(key);
485 assert_eq!(h.scheduled_count(), 0);
486
487 // And one that fired on its own.
488 let (tx, rx) = mpsc::channel();
489 let fired = h
490 .schedule_wake(
491 Duration::from_millis(10),
492 Box::new(move || tx.send(()).unwrap()),
493 )
494 .expect("a live timer must queue the wake");
495 assert_eq!(rx.recv_timeout(T), Ok(()));
496 h.cancel_wake(fired);
497 assert_eq!(h.scheduled_count(), 0);
498 }
499
500 /// The ordering the key encodes is the ordering the queue had: earliest
501 /// deadline first, ties in submission order. `BinaryHeap` got that from a
502 /// reversed `Ord` on the entry; the map gets it from the key's natural one.
503 #[test]
504 fn equal_deadlines_fire_in_submission_order() {
505 let pool = CallbackPool::new();
506 let timer = DelayedTimer::new(pool.handle());
507 let (tx, rx) = mpsc::channel();
508
509 for n in 0..4 {
510 let tx = tx.clone();
511 timer.schedule(
512 Duration::from_millis(20),
513 CallbackPriority::Medium,
514 Box::new(move || tx.send(n).unwrap()),
515 );
516 }
517 let got: Vec<i32> = (0..4).map(|_| rx.recv_timeout(T).unwrap()).collect();
518 assert_eq!(got, vec![0, 1, 2, 3]);
519 }
520
521 #[test]
522 fn schedule_after_shutdown_never_fires() {
523 // Boundary: a TimerHandle that outlives the timer must drop late
524 // requests silently — the callback must never reach the sink pool.
525 let pool = CallbackPool::new();
526 let timer = DelayedTimer::new(pool.handle());
527 let h = timer.handle();
528 drop(timer); // sets shutdown, joins the timer thread.
529
530 let (tx, rx) = mpsc::channel::<()>();
531 h.schedule(
532 Duration::from_millis(0),
533 CallbackPriority::High,
534 Box::new(move || tx.send(()).unwrap()),
535 );
536 // The callback (and its `tx`) is dropped synchronously inside
537 // schedule()'s shutdown branch — never scheduled, never fired. The
538 // receiver therefore sees the sender gone (Disconnected), and never a
539 // delivered `()`.
540 assert_eq!(
541 rx.recv_timeout(Duration::from_millis(200)),
542 Err(mpsc::RecvTimeoutError::Disconnected),
543 "a delayed callback fired after shutdown; it must be dropped"
544 );
545 }
546}