Skip to main content

epics_libcom_rs/runtime/
taskwd.rs

1//! `taskwd` — the IOC's task watchdog, C `libCom/src/taskwd/taskwd.c`
2//! (R7.0.10).
3//!
4//! One low-priority thread wakes every [`TASKWD_DELAY`] and asks each
5//! registered task whether it is still running. When one stops running the
6//! watchdog says so on the console, calls that task's own callback, and tells
7//! every registered monitor — which is how a C IOC turns a wedged scan thread
8//! into an operator-visible event instead of records that quietly stop
9//! updating.
10//!
11//! This is **not** a thread registry. Nothing here enumerates threads, names
12//! them or reports their stacks; a task is here because it asked to be
13//! watched, and the list is the watchdog's, not the process's.
14//!
15//! # What "suspended" means here
16//!
17//! C's watchdog polls `epicsThreadIsSuspended(tid)` (`taskwd.c:99`). On
18//! vxWorks that is a real OS state; on POSIX it is a flag `epicsThreadSuspendSelf`
19//! sets, so a C IOC on Linux only ever reports a thread that suspended
20//! *itself* — out of memory, `cantProceed`. Rust has neither: a thread cannot
21//! be suspended by another and never suspends itself.
22//!
23//! So the port asks the question the other way round, which is the only way it
24//! can be asked here: a task checks in ([`TaskwdEntry::check_in`]) as it goes
25//! round its loop, and a task that stops checking in inside the interval it
26//! declared is this port's *suspended*. That covers strictly more than C's
27//! POSIX build does — a thread wedged on a lock or an unbounded read is
28//! invisible to `epicsThreadIsSuspended` and visible here — and the operator's
29//! side of it is unchanged: the same console line, the same callback, the same
30//! `taskwdShow` state column.
31//!
32//! A task that cannot promise to come back — one parked in `accept()`, or in a
33//! blocking read with no deadline — registers [`CheckIn::Unbounded`] and is
34//! listed but never reported, which is exactly what C's POSIX build does with
35//! every one of its tasks.
36//!
37//! # Identity is the registration, not a thread id
38//!
39//! C keys everything on `epicsThreadId`, because the state it polls belongs to
40//! a thread. Half the port's equivalents of C's call sites are futures, which
41//! have no thread of their own and may run on a different one after every
42//! await, so a thread id would name the wrong thing for them and the right
43//! thing for the others — one field, two meanings. Registration returns a
44//! [`TaskwdEntry`] instead, and [`TaskwdId`] is what the monitor API carries.
45//!
46//! The handle is also what removes the task: C pairs every `taskwdInsert` with
47//! a `taskwdRemove` on each exit path and errlogs when it is passed a thread
48//! that was never inserted (`taskwd.c:241-243`). Dropping the handle removes
49//! it, on every path including a panic, and there is no way to ask for the
50//! removal of something that was never registered.
51//!
52//! # Not ported
53//!
54//! * `taskwdAnyInsert` / `taskwdAnyRemove` (`taskwd.c:306-354`) — the
55//!   deprecated pre-3.15 monitor API, which C implements as a monitor whose
56//!   `notify` fires only on suspension. Nothing in base or in the modules this
57//!   workspace ports calls it; the [`TaskwdMonitor`] trait is what it wraps.
58//! * The free-node pool (`taskwd.c:395-430`) and the `%d free nodes` it puts in
59//!   the report. It is an allocator for three C structs that share a union;
60//!   Rust drops the entry instead, so there is no pool to count.
61//! * `twdctlDisable` (`taskwd.c:74`) — the enum has the state, nothing in
62//!   R7.0.10 ever assigns it.
63
64use std::sync::atomic::{AtomicU64, Ordering};
65use std::sync::{Arc, Condvar, LazyLock, Mutex, Once};
66use std::time::{Duration, Instant};
67
68use crate::runtime::sync::PriorityInheritanceMutex;
69use crate::runtime::task::{MandatoryThread, StackSizeClass, ThreadPriority};
70
71/// How often the watchdog looks at its list — C `TASKWD_DELAY` (`taskwd.c:80`).
72pub const TASKWD_DELAY: Duration = Duration::from_secs(6);
73
74/// What a task promises about coming back to check in.
75///
76/// The promise is the task's, not the watchdog's: it is the interval after
77/// which *the task itself* considers a missed check-in a fault, so a scan
78/// thread on a 10 Hz rate promises seconds, not milliseconds.
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum CheckIn {
81    /// Report the task once this long has passed with no check-in.
82    ///
83    /// Detection is not instant: the watchdog only looks every
84    /// [`TASKWD_DELAY`], so a task is reported between `d` and `d +
85    /// TASKWD_DELAY` after its last check-in. C has the same granularity for
86    /// the same reason.
87    Every(Duration),
88    /// The task makes no promise — it is parked in something with no deadline
89    /// of its own. Listed by [`taskwd_show`], never reported.
90    Unbounded,
91}
92
93/// The watchdog's name for one registration — C's `epicsThreadId` in the
94/// monitor API, without the claim that a task is a thread.
95#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
96pub struct TaskwdId(u64);
97
98/// What a monitor is told about, and what [`taskwd_show`] lists.
99#[derive(Clone, Debug)]
100pub struct TaskInfo {
101    /// Identity of this registration.
102    pub id: TaskwdId,
103    /// The name the task registered under. C reads the thread's name out of
104    /// the OS at report time (`epicsThreadGetName`, `taskwd.c:113`); a task
105    /// here names itself, because a future has no thread name to read.
106    pub name: String,
107}
108
109/// A task's own answer to being found stuck — C's `TASKWDFUNC` and its `usr`
110/// pointer collapsed into one closure (`taskwd.h:33`).
111///
112/// Called from the watchdog thread, with no watchdog lock held.
113pub type TaskwdCallback = Arc<dyn Fn() + Send + Sync>;
114
115/// Something watching every task, rather than one — C's `taskwdMonitor`
116/// (`taskwd.h:41-45`). Every method is optional there (a `NULL` slot is
117/// skipped), so every method here has a do-nothing default.
118///
119/// Called from the registering task's thread (`insert`, `remove`) or the
120/// watchdog thread (`notify`), always with no watchdog lock held, so a monitor
121/// may call back into this module.
122pub trait TaskwdMonitor: Send + Sync {
123    /// A task joined the watchdog's list.
124    fn insert(&self, task: &TaskInfo) {
125        let _ = task;
126    }
127    /// A task changed state. `suspended` is the new state, so a monitor sees
128    /// both the fault and the recovery — C notifies on the transition either
129    /// way (`taskwd.c:100-121`).
130    fn notify(&self, task: &TaskInfo, suspended: bool) {
131        let _ = (task, suspended);
132    }
133    /// A task left the list.
134    fn remove(&self, task: &TaskInfo) {
135        let _ = task;
136    }
137}
138
139/// One watched task — C's `struct tNode` (`taskwd.c:34-40`).
140struct TaskEntry {
141    info: TaskInfo,
142    check_in: CheckIn,
143    /// Bumped by the task, sampled by the watchdog. A counter and not a
144    /// timestamp so that checking in costs one relaxed increment in a hot
145    /// loop, and so that every reading of the clock belongs to the watchdog.
146    beat: Arc<AtomicU64>,
147    /// The watchdog's copy of `beat` from its previous look.
148    seen_beat: u64,
149    /// When the watchdog last saw `beat` move. Seeded at insert so a task that
150    /// never checks in at all is reported after its own interval.
151    last_move: Instant,
152    /// C's `pt->suspended` (`taskwd.c:39`): the remembered state, so what is
153    /// reported is the transition and not the level.
154    suspended: bool,
155    callback: Option<TaskwdCallback>,
156}
157
158/// One registered monitor — C's `struct mNode` (`taskwd.c:42-46`). C keys
159/// removal on the `(funcs, usr)` pair; a handle is this port's key.
160struct MonitorEntry {
161    id: u64,
162    monitor: Arc<dyn TaskwdMonitor>,
163}
164
165/// C's `twdCtl` (`taskwd.c:73-75`), less the state nothing assigns.
166#[derive(Clone, Copy, PartialEq, Eq)]
167enum Ctl {
168    Run,
169    Exit,
170}
171
172struct Taskwd {
173    /// C's `tList` under `tLock` (`taskwd.c:61-62`).
174    tasks: PriorityInheritanceMutex<Vec<TaskEntry>>,
175    /// C's `mList` under `mLock` (`taskwd.c:65-66`).
176    monitors: PriorityInheritanceMutex<Vec<MonitorEntry>>,
177    /// C's `loopEvent` (`taskwd.c:76`) — a condvar because the only waiter is
178    /// the watchdog itself.
179    ctl: Mutex<Ctl>,
180    wake: Condvar,
181    /// C waits on `exitEvent` for the loop to finish (`taskwd.c:137`); joining
182    /// the thread is the same wait with the handle doing the bookkeeping.
183    thread: Mutex<Option<std::thread::JoinHandle<()>>>,
184    started: Once,
185    next_id: AtomicU64,
186    /// C's `TASKWD_DELAY` is a compile-time constant (`taskwd.c:80`). Here the
187    /// watchdog reads it every pass, so a test can watch a task fault without
188    /// waiting six seconds for the pass that notices.
189    period_ms: AtomicU64,
190}
191
192/// The process's watchdog — C's file-scope `tList` / `mList` / `twdCtl`
193/// (`taskwd.c:60-77`), which is the only instance a C IOC can have.
194///
195/// The state machine above is not written against it: everything a watchdog
196/// does is a method on an instance, so a test can hold its own, drive [`scan`]
197/// from a clock it controls, and never race the thread this one runs.
198///
199/// [`scan`]: Taskwd::scan
200static TASKWD: LazyLock<Arc<Taskwd>> = LazyLock::new(Taskwd::new);
201
202impl Taskwd {
203    fn new() -> Arc<Self> {
204        Arc::new(Taskwd {
205            tasks: PriorityInheritanceMutex::new(Vec::new()),
206            monitors: PriorityInheritanceMutex::new(Vec::new()),
207            ctl: Mutex::new(Ctl::Run),
208            wake: Condvar::new(),
209            thread: Mutex::new(None),
210            started: Once::new(),
211            next_id: AtomicU64::new(1),
212            period_ms: AtomicU64::new(TASKWD_DELAY.as_millis() as u64),
213        })
214    }
215
216    /// C `twdInitOnce` (`taskwd.c:145-166`): start the one watchdog thread, or
217    /// take the process down trying — `MandatoryThread` is `cantProceed`
218    /// (`taskwd.c:162-163`) with the console text in one place.
219    fn start(self: &Arc<Self>) {
220        let owner = self.clone();
221        self.started.call_once(|| {
222            *self.ctl.lock().unwrap_or_else(|e| e.into_inner()) = Ctl::Run;
223            let runner = owner.clone();
224            let handle = MandatoryThread::new("taskwd", ThreadPriority::Low, StackSizeClass::Small)
225                .spawn(move || runner.run());
226            *self.thread.lock().unwrap_or_else(|e| e.into_inner()) = Some(handle);
227            // C `epicsAtExit(twdShutdown, NULL)` (`taskwd.c:165`).
228            crate::runtime::exit::at_exit("taskwd", move || owner.shutdown());
229        });
230    }
231
232    /// C `twdTask` (`taskwd.c:89-129`).
233    fn run(&self) {
234        loop {
235            let ctl = *self.ctl.lock().unwrap_or_else(|e| e.into_inner());
236            if ctl == Ctl::Exit {
237                return;
238            }
239            self.scan(Instant::now());
240            let period = Duration::from_millis(self.period_ms.load(Ordering::Relaxed));
241            let guard = self.ctl.lock().unwrap_or_else(|e| e.into_inner());
242            let (guard, _) = self
243                .wake
244                .wait_timeout_while(guard, period, |c| *c != Ctl::Exit)
245                .unwrap_or_else(|e| e.into_inner());
246            if *guard == Ctl::Exit {
247                return;
248            }
249        }
250    }
251
252    /// C `twdShutdown` (`taskwd.c:132-143`).
253    fn shutdown(&self) {
254        *self.ctl.lock().unwrap_or_else(|e| e.into_inner()) = Ctl::Exit;
255        self.wake.notify_all();
256        let handle = self.thread.lock().unwrap_or_else(|e| e.into_inner()).take();
257        if let Some(handle) = handle {
258            let _ = handle.join();
259        }
260    }
261
262    /// One pass over the list — C `twdTask`'s body (`taskwd.c:96-124`).
263    ///
264    /// The callbacks and monitors C runs while holding `tLock` are run here
265    /// after it is released. C can hold it because `epicsMutex` is recursive,
266    /// so a callback that calls `taskwdShow` merely re-enters; ours is not, and
267    /// that call would deadlock. The observable difference is confined to a
268    /// task removed in the window between the two, whose callback can still
269    /// fire — and that task is by definition the one that stopped running.
270    fn scan(&self, now: Instant) {
271        let mut transitions: Vec<(TaskInfo, bool, Option<TaskwdCallback>)> = Vec::new();
272        {
273            let mut tasks = self.tasks.lock();
274            for task in tasks.iter_mut() {
275                let beat = task.beat.load(Ordering::Relaxed);
276                if beat != task.seen_beat {
277                    task.seen_beat = beat;
278                    task.last_move = now;
279                }
280                let suspended = match task.check_in {
281                    CheckIn::Unbounded => false,
282                    CheckIn::Every(deadline) => {
283                        now.saturating_duration_since(task.last_move) >= deadline
284                    }
285                };
286                if suspended != task.suspended {
287                    task.suspended = suspended;
288                    transitions.push((task.info.clone(), suspended, task.callback.clone()));
289                }
290            }
291        }
292
293        for (info, suspended, callback) in transitions {
294            for monitor in self.monitor_snapshot() {
295                monitor.notify(&info, suspended);
296            }
297            if suspended {
298                // C's wording, because it is what an operator greps for
299                // (`taskwd.c:114-115`).
300                crate::runtime::log::errlog_printf(&format!(
301                    "Thread {} ({}) suspended\n",
302                    info.name, info.id.0
303                ));
304                if let Some(callback) = callback {
305                    callback();
306                }
307            }
308        }
309    }
310
311    fn monitor_snapshot(&self) -> Vec<Arc<dyn TaskwdMonitor>> {
312        self.monitors
313            .lock()
314            .iter()
315            .map(|m| m.monitor.clone())
316            .collect()
317    }
318}
319
320/// Start the watchdog thread if it is not running — C `taskwdInit`
321/// (`taskwd.c:168-172`), which `iocInit` calls early (`iocInit.c:151`) and
322/// every registration calls for itself.
323pub fn taskwd_init() {
324    TASKWD.start();
325}
326
327/// Watch this task — C `taskwdInsert` (`taskwd.c:177-205`).
328///
329/// `name` is what the console line and [`taskwd_show`] call it; give it the
330/// name the thread or task already has, so an operator can match the two.
331/// `callback` is what the task wants done when it is found stuck (C's
332/// `TASKWDFUNC`); most call sites have none.
333///
334/// The task is watched until the returned handle is dropped.
335pub fn taskwd_insert(
336    name: impl Into<String>,
337    check_in: CheckIn,
338    callback: Option<TaskwdCallback>,
339) -> TaskwdEntry {
340    taskwd_init();
341    TASKWD.insert(name, check_in, callback)
342}
343
344/// A watched task's registration. Dropping it stops the watch — C
345/// `taskwdRemove` (`taskwd.c:207-244`) on every exit path, including the ones
346/// C's callers have to remember.
347pub struct TaskwdEntry {
348    owner: Arc<Taskwd>,
349    info: TaskInfo,
350    beat: Arc<AtomicU64>,
351}
352
353impl TaskwdEntry {
354    /// "Still here" — call it once per pass round the task's loop.
355    ///
356    /// One relaxed increment: cheap enough for a loop that runs at scan rate,
357    /// and it reads no clock, so the watchdog stays the only owner of the
358    /// timing question.
359    pub fn check_in(&self) {
360        self.beat.fetch_add(1, Ordering::Relaxed);
361    }
362
363    /// What the monitors and [`taskwd_show`] know this task as.
364    pub fn info(&self) -> &TaskInfo {
365        &self.info
366    }
367}
368
369impl Drop for TaskwdEntry {
370    fn drop(&mut self) {
371        let removed = {
372            let mut tasks = self.owner.tasks.lock();
373            match tasks.iter().position(|t| t.info.id == self.info.id) {
374                Some(idx) => {
375                    tasks.remove(idx);
376                    true
377                }
378                None => false,
379            }
380        };
381        // C errlogs when the tid it was handed is not in the list
382        // (`taskwd.c:241-243`); with the handle owning the registration the
383        // only way here is a second drop, which Rust does not do.
384        if removed {
385            for monitor in self.owner.monitor_snapshot() {
386                monitor.remove(&self.info);
387            }
388        }
389    }
390}
391
392/// Watch every task — C `taskwdMonitorAdd` (`taskwd.c:249-264`).
393///
394/// The monitor is registered until the returned handle is dropped, which is C's
395/// `taskwdMonitorDel` (`taskwd.c:266-288`).
396pub fn taskwd_monitor_add(monitor: Arc<dyn TaskwdMonitor>) -> TaskwdMonitorEntry {
397    taskwd_init();
398    TASKWD.monitor_add(monitor)
399}
400
401/// A registered monitor's handle. Dropping it unregisters the monitor.
402pub struct TaskwdMonitorEntry {
403    owner: Arc<Taskwd>,
404    id: u64,
405}
406
407impl Drop for TaskwdMonitorEntry {
408    fn drop(&mut self) {
409        self.owner.monitors.lock().retain(|m| m.id != self.id);
410    }
411}
412
413/// Report the watchdog's list — C `taskwdShow` (`taskwd.c:359-390`).
414///
415/// `out` takes one line at a time, the shape the port's other report functions
416/// use (`epics_base_rs::server::db_server::dbsr`). Registering it as the iocsh
417/// `taskwdShow` command is `libComRegister.c`'s job, not this module's.
418///
419/// Unlike C's, this answers before anything has been registered: C locks
420/// mutexes that `taskwdInit` creates, so `taskwdShow` on a process that never
421/// initialised the watchdog dereferences a null mutex.
422pub fn taskwd_show(level: u32, out: &dyn Fn(&str)) {
423    TASKWD.show(level, out)
424}
425
426impl Taskwd {
427    /// C `taskwdInsert` (`taskwd.c:177-205`).
428    fn insert(
429        self: &Arc<Self>,
430        name: impl Into<String>,
431        check_in: CheckIn,
432        callback: Option<TaskwdCallback>,
433    ) -> TaskwdEntry {
434        let info = TaskInfo {
435            id: TaskwdId(self.next_id.fetch_add(1, Ordering::Relaxed)),
436            name: name.into(),
437        };
438        let beat = Arc::new(AtomicU64::new(0));
439
440        // C tells the monitors before the task joins the list
441        // (`taskwd.c:192-204`), so a monitor that reports the list from its own
442        // `insert` does not see the task it is being told about.
443        for monitor in self.monitor_snapshot() {
444            monitor.insert(&info);
445        }
446
447        self.tasks.lock().push(TaskEntry {
448            info: info.clone(),
449            check_in,
450            beat: beat.clone(),
451            seen_beat: 0,
452            last_move: Instant::now(),
453            suspended: false,
454            callback,
455        });
456
457        TaskwdEntry {
458            owner: self.clone(),
459            info,
460            beat,
461        }
462    }
463
464    /// C `taskwdMonitorAdd` (`taskwd.c:249-264`).
465    fn monitor_add(self: &Arc<Self>, monitor: Arc<dyn TaskwdMonitor>) -> TaskwdMonitorEntry {
466        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
467        self.monitors.lock().push(MonitorEntry { id, monitor });
468        TaskwdMonitorEntry {
469            owner: self.clone(),
470            id,
471        }
472    }
473
474    /// C `taskwdShow` (`taskwd.c:359-390`).
475    ///
476    /// Two fields of C's summary line have no counterpart here and are left
477    /// off rather than faked. C's `%d free nodes` counts `fList`, the pool of
478    /// recycled `union twdNode`s that `freeNode` pushes instead of calling
479    /// `free` (`taskwd.c:393-420`); an entry here is removed by
480    /// [`TaskwdEntry`]'s `Drop` and its memory returned to the allocator, so
481    /// there is no pool to count and the number would be a constant zero
482    /// dressed as a measurement. C's noun is "threads" because its `tList`
483    /// holds `epicsThreadId`s and it reads each name back with
484    /// `epicsThreadGetName` at print time; several rows here are futures on a
485    /// banded executor rather than threads of their own, which is why the
486    /// name is carried in the entry and the noun is "tasks".
487    fn show(&self, level: u32, out: &dyn Fn(&str)) {
488        let monitors = self.monitors.lock().len();
489        let tasks = self.tasks.lock();
490        out(&format!(
491            "{} monitors, {} tasks registered",
492            monitors,
493            tasks.len()
494        ));
495        if level == 0 {
496            return;
497        }
498        // C can fix its name column at `%16.16s` because every name it prints
499        // is an `epicsThreadGetName` of a short literal, and identity lives in
500        // the `EPICS TID` column beside it. This table has no such column, so
501        // a per-connection row carries its peer in the name — which a fixed 16
502        // truncated at exactly the character that made it distinct
503        // (`CAS-TCP 0.0.0.0:5064` printed as `CAS-TCP 0.0.0.0:`). Width is the
504        // widest name present, floored at C's 16 so a table of C-shaped names
505        // still lays out as C's does.
506        let width = tasks
507            .iter()
508            .map(|t| t.info.name.chars().count())
509            .max()
510            .unwrap_or(0)
511            .max(16);
512        out(&format!(
513            "{:width$} {:>9} {:>12} {:>12} {:>8}",
514            "TASK NAME", "STATE", "CHECK-IN", "LAST BEAT", "CALLBACK"
515        ));
516        let now = Instant::now();
517        for task in tasks.iter() {
518            let check_in = match task.check_in {
519                CheckIn::Unbounded => "unbounded".to_string(),
520                CheckIn::Every(d) => format!("{:.1}s", d.as_secs_f64()),
521            };
522            out(&format!(
523                "{:width$} {:>9} {:>12} {:>11.1}s {:>8}",
524                task.info.name,
525                if task.suspended { "Suspended" } else { "Ok" },
526                check_in,
527                now.saturating_duration_since(task.last_move).as_secs_f64(),
528                if task.callback.is_some() { "yes" } else { "-" }
529            ));
530        }
531    }
532}
533
534#[cfg(test)]
535impl Taskwd {
536    /// C's scan interval is a constant; the one test that waits for the real
537    /// watchdog thread to notice something cannot wait six seconds for it.
538    fn set_period_for_test(&self, period: Duration) {
539        self.period_ms
540            .store(period.as_millis() as u64, Ordering::Relaxed);
541        self.wake.notify_all();
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use std::sync::Mutex as StdMutex;
549
550    /// Records what a monitor was told, and in what order.
551    #[derive(Default)]
552    struct Recorder {
553        log: StdMutex<Vec<String>>,
554        /// The instance to report from inside `insert`, so the test can see
555        /// what the list looked like at that moment.
556        watched: StdMutex<Option<Arc<Taskwd>>>,
557    }
558
559    impl Recorder {
560        fn taken(&self) -> Vec<String> {
561            std::mem::take(&mut *self.log.lock().unwrap_or_else(|e| e.into_inner()))
562        }
563        fn push(&self, line: String) {
564            self.log
565                .lock()
566                .unwrap_or_else(|e| e.into_inner())
567                .push(line);
568        }
569    }
570
571    impl TaskwdMonitor for Recorder {
572        fn insert(&self, task: &TaskInfo) {
573            let watched = self
574                .watched
575                .lock()
576                .unwrap_or_else(|e| e.into_inner())
577                .clone();
578            let counts = std::cell::RefCell::new(Vec::new());
579            if let Some(watched) = watched {
580                watched.show(0, &|line: &str| counts.borrow_mut().push(line.to_string()));
581            }
582            self.push(format!(
583                "insert {} [{}]",
584                task.name,
585                counts.into_inner().join("")
586            ));
587        }
588        fn notify(&self, task: &TaskInfo, suspended: bool) {
589            self.push(format!("notify {} suspended={suspended}", task.name));
590        }
591        fn remove(&self, task: &TaskInfo) {
592            self.push(format!("remove {}", task.name));
593        }
594    }
595
596    /// A watchdog with no thread: every test below drives [`Taskwd::scan`] from
597    /// a clock it owns, so nothing races it and nothing sleeps.
598    fn watchdog() -> Arc<Taskwd> {
599        Taskwd::new()
600    }
601
602    fn rows(twd: &Taskwd, level: u32) -> Vec<String> {
603        let out = std::cell::RefCell::new(Vec::new());
604        twd.show(level, &|line: &str| out.borrow_mut().push(line.to_string()));
605        out.into_inner()
606    }
607
608    fn state_of(twd: &Taskwd, name: &str) -> String {
609        rows(twd, 1)
610            .into_iter()
611            .find(|r| r.starts_with(name))
612            .unwrap_or_else(|| panic!("{name} is not in the report"))
613    }
614
615    #[test]
616    fn a_task_that_keeps_checking_in_is_not_reported() {
617        let twd = watchdog();
618        let task = twd.insert("keeps-up", CheckIn::Every(Duration::from_secs(1)), None);
619        let start = Instant::now();
620
621        task.check_in();
622        twd.scan(start + Duration::from_secs(2));
623
624        assert!(
625            state_of(&twd, "keeps-up").contains("Ok"),
626            "{}",
627            state_of(&twd, "keeps-up")
628        );
629    }
630
631    /// The boundary the whole module turns on: below the deadline is silence,
632    /// at it comes the report, the callback and the notify.
633    #[test]
634    fn a_task_that_stops_checking_in_is_reported_at_its_own_deadline() {
635        let twd = watchdog();
636        let recorder = Arc::new(Recorder::default());
637        let _monitor = twd.monitor_add(recorder.clone());
638
639        let fired = Arc::new(AtomicU64::new(0));
640        let counter = fired.clone();
641        let _task = twd.insert(
642            "wedged",
643            CheckIn::Every(Duration::from_secs(1)),
644            Some(Arc::new(move || {
645                counter.fetch_add(1, Ordering::Relaxed);
646            })),
647        );
648        // After the insert, so the deadline is measured from a moment at or
649        // after the task's own seed rather than before it.
650        let start = Instant::now();
651        let _ = recorder.taken();
652
653        twd.scan(start + Duration::from_millis(999));
654        assert_eq!(fired.load(Ordering::Relaxed), 0, "below its deadline");
655        assert!(recorder.taken().is_empty(), "nothing to notify below it");
656
657        twd.scan(start + Duration::from_secs(1));
658        assert_eq!(fired.load(Ordering::Relaxed), 1, "at its deadline");
659        assert_eq!(recorder.taken(), vec!["notify wedged suspended=true"]);
660        assert!(state_of(&twd, "wedged").contains("Suspended"));
661
662        // C reports the transition, not the level: a second pass with the task
663        // still stuck says nothing more (`taskwd.c:100`).
664        twd.scan(start + Duration::from_secs(30));
665        assert_eq!(fired.load(Ordering::Relaxed), 1, "the transition, once");
666        assert!(recorder.taken().is_empty());
667    }
668
669    #[test]
670    fn a_task_that_checks_in_again_is_reported_recovered() {
671        let twd = watchdog();
672        let recorder = Arc::new(Recorder::default());
673        let _monitor = twd.monitor_add(recorder.clone());
674
675        let task = twd.insert("recovers", CheckIn::Every(Duration::from_secs(1)), None);
676        let start = Instant::now();
677        twd.scan(start + Duration::from_secs(1));
678        let _ = recorder.taken();
679
680        task.check_in();
681        twd.scan(start + Duration::from_secs(2));
682
683        assert_eq!(recorder.taken(), vec!["notify recovers suspended=false"]);
684        assert!(state_of(&twd, "recovers").contains("Ok"));
685    }
686
687    /// C's POSIX build never reports any task, because nothing suspends a
688    /// thread. That is what a task with no deadline of its own asks for.
689    #[test]
690    fn an_unbounded_task_is_listed_and_never_reported() {
691        let twd = watchdog();
692        let recorder = Arc::new(Recorder::default());
693        let _monitor = twd.monitor_add(recorder.clone());
694
695        let _task = twd.insert("parked-in-accept", CheckIn::Unbounded, None);
696        let start = Instant::now();
697        let _ = recorder.taken();
698
699        twd.scan(start + Duration::from_secs(86_400));
700
701        assert!(
702            recorder.taken().is_empty(),
703            "an unbounded task is never late"
704        );
705        assert!(state_of(&twd, "parked-in-accept").contains("Ok"));
706    }
707
708    #[test]
709    fn dropping_the_handle_removes_the_task_and_tells_the_monitors() {
710        let twd = watchdog();
711        let recorder = Arc::new(Recorder::default());
712        let _monitor = twd.monitor_add(recorder.clone());
713
714        let before = rows(&twd, 0);
715        let task = twd.insert("short-lived", CheckIn::Unbounded, None);
716        assert_ne!(rows(&twd, 0), before, "in the count while it lives");
717        let _ = recorder.taken();
718
719        drop(task);
720
721        assert_eq!(rows(&twd, 0), before, "and out of it once dropped");
722        assert_eq!(recorder.taken(), vec!["remove short-lived"]);
723    }
724
725    /// C calls the monitors' `insert` before the task joins the list
726    /// (`taskwd.c:192-204`), so a monitor reporting from inside it sees the
727    /// count without the new task.
728    #[test]
729    fn a_monitor_is_told_before_the_task_joins_the_list() {
730        let twd = watchdog();
731        let recorder = Arc::new(Recorder::default());
732        *recorder.watched.lock().unwrap() = Some(twd.clone());
733        let _monitor = twd.monitor_add(recorder.clone());
734
735        let _task = twd.insert("newcomer", CheckIn::Unbounded, None);
736
737        assert_eq!(
738            recorder.taken(),
739            vec!["insert newcomer [1 monitors, 0 tasks registered]"]
740        );
741    }
742
743    /// C's `%16.16s` fits because every C name is a short literal read back
744    /// with `epicsThreadGetName`, and identity is in the `EPICS TID` column.
745    /// Rows here carry their peer or bind address in the name instead, and a
746    /// fixed 16 cut `CAS-TCP 0.0.0.0:5064` down to `CAS-TCP 0.0.0.0:` — the
747    /// truncation landed on exactly the part that made the row distinct.
748    #[test]
749    fn a_name_longer_than_cs_column_is_not_truncated() {
750        let twd = watchdog();
751        let long = "CAS-client 192.168.0.44:34122";
752        let _short = twd.insert("cbLow", CheckIn::Unbounded, None);
753        let _task = twd.insert(long, CheckIn::Unbounded, None);
754
755        let detailed = rows(&twd, 1);
756        assert!(
757            detailed.iter().any(|r| r.starts_with(long)),
758            "the whole name must survive: {detailed:?}"
759        );
760        // Header and every row share the one width, so the STATE field still
761        // ends at one column once a long name has widened them.
762        let head = detailed[1].find("STATE").unwrap() + "STATE".len();
763        for row in &detailed[2..] {
764            assert_eq!(
765                row.find("Ok").unwrap() + "Ok".len(),
766                head,
767                "STATE must end at one column for header and rows: {detailed:?}"
768            );
769        }
770    }
771
772    /// The floor: a table holding only C-shaped names lays out at C's 16, so
773    /// the widening rule costs nothing on an IOC that never registers one.
774    #[test]
775    fn short_names_keep_cs_sixteen_wide_layout() {
776        let twd = watchdog();
777        let _task = twd.insert("cbLow", CheckIn::Unbounded, None);
778
779        let detailed = rows(&twd, 1);
780        assert_eq!(
781            detailed[1].find("STATE"),
782            // C's `%16.16s %9s`: 16 for the name, the separating space, then
783            // STATE right-aligned in 9.
784            Some(16 + 1 + 4),
785            "{:?}",
786            detailed[1]
787        );
788    }
789
790    #[test]
791    fn the_report_is_counts_alone_until_a_level_is_asked_for() {
792        let twd = watchdog();
793        let _task = twd.insert("listed", CheckIn::Every(Duration::from_secs(2)), None);
794
795        assert_eq!(rows(&twd, 0), vec!["0 monitors, 1 tasks registered"]);
796        let detailed = rows(&twd, 1);
797        assert_eq!(detailed.len(), 3, "counts, header, one task: {detailed:?}");
798        assert!(detailed[1].contains("TASK NAME"));
799        assert!(detailed[2].contains("2.0s"), "its declared interval");
800    }
801
802    /// The wiring, end to end and on the process's own watchdog: the real
803    /// thread notices, says so on the console with C's wording, and calls the
804    /// task's callback.
805    #[test]
806    fn the_watchdog_thread_reports_a_task_that_stops_checking_in() {
807        let (console_tx, console_rx) = std::sync::mpsc::channel::<String>();
808        let listener = crate::runtime::log::errlog_add_listener(move |m: &str| {
809            if m.contains("stalls") {
810                let _ = console_tx.send(m.to_string());
811            }
812        });
813
814        let (fired_tx, fired_rx) = std::sync::mpsc::channel::<()>();
815        let _task = taskwd_insert(
816            "stalls",
817            CheckIn::Every(Duration::from_millis(20)),
818            Some(Arc::new(move || {
819                let _ = fired_tx.send(());
820            })),
821        );
822        TASKWD.set_period_for_test(Duration::from_millis(10));
823
824        assert!(
825            fired_rx.recv_timeout(Duration::from_secs(10)).is_ok(),
826            "the watchdog thread must call a stalled task's callback"
827        );
828        let line = console_rx
829            .recv_timeout(Duration::from_secs(10))
830            .expect("and say so on the console");
831        assert!(
832            line.contains("suspended"),
833            "C's wording is what an operator greps for: {line}"
834        );
835
836        crate::runtime::log::errlog_remove_listener(listener);
837    }
838}