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}