Skip to main content

epics_libcom_rs/runtime/background/
facility.rs

1//! What keeps a facility thread alive, and what is said when one dies.
2//!
3//! Three single-purpose worker threads carry every deferred and timed
4//! operation this runtime offers: the delayed-callback timer
5//! ([`super::delayed_timer`]) behind `sleep`, `interval`, scan periods and
6//! `callbackRequestDelayed`; the scanOnce worker ([`super::scan_once`]) behind
7//! FLNK and `scanOnce`; and the callback bands
8//! ([`super::callback_executor`]). Each owns a `Mutex` + `Condvar` queue and
9//! runs callbacks its callers supplied. Two defects follow from that shape,
10//! and they were the same defect in all three files:
11//!
12//! * `.lock().unwrap()` propagates a poisoned mutex. Poison means "a thread
13//!   panicked while holding this", not "the data is broken" — these queues are
14//!   plain collections whose invariants survive a panic — so propagating turns
15//!   one caller's panic into the loss of the whole facility.
16//! * A caller-supplied callback runs **on the facility thread**. An unwinding
17//!   callback therefore unwinds the loop and the thread ends. Nothing
18//!   announces it: submitters keep enqueueing, the IOC keeps answering CA and
19//!   PVA, and every timed operation has simply stopped. An operator sees a
20//!   healthy IOC whose records no longer process.
21//!
22//! C reaches neither state: there is no unwinding, and `callbackTask`
23//! (`callback.c:210-235`) cannot be ended by the callback it invoked.
24//!
25//! On a target built with `panic = "abort"` the two `catch_unwind`s here never
26//! catch and the process aborts instead. That is a different outcome but not a
27//! silent one, which is the property being defended.
28
29use std::any::Any;
30use std::cell::Cell;
31use std::panic::{AssertUnwindSafe, catch_unwind};
32
33use crate::runtime::log::{ErrlogSevEnum, errlog_sev_printf};
34
35thread_local! {
36    /// Set for the whole life of a facility worker loop — see
37    /// [`on_facility_thread`].
38    ///
39    /// Private to this module on purpose: [`run_facility_loop`] is the only
40    /// thing that can set it, and every facility worker thread runs its loop
41    /// through that function (`no_facility_propagates_a_poisoned_lock` pins
42    /// that for all three), so "this thread is a facility worker" cannot be
43    /// asserted by anything that is not one, and cannot be *missed* by
44    /// anything that is.
45    static ON_FACILITY_THREAD: Cell<bool> = const { Cell::new(false) };
46}
47
48/// Is the calling thread a background-facility worker — a callback band
49/// ([`super::callback_executor`]), the delayed-callback timer
50/// ([`super::delayed_timer`]) or the scanOnce worker ([`super::scan_once`])?
51///
52/// **The invariant this exists for.** Each facility has a bounded worker set
53/// (one thread per band by default, C `callbackThreadsDefault`, and exactly one
54/// for the timer and for scanOnce), and every unit of work it carries is
55/// enqueued *for that worker*. A worker that parks waiting for async progress
56/// is therefore waiting for something only it could have run: the deferred
57/// callbacks, the FLNK tails, the monitor tails and — on RTEMS, where
58/// [`crate::runtime::task::spawn`] routes here — every other spawned future on
59/// that band. Parking the delayed timer is worse still: it stops every `sleep`,
60/// `interval` and scan period in the process, so a future waiting on a timeout
61/// waits forever.
62///
63/// [`crate::runtime::task::block_on_sync`] is the single gate that consults
64/// this, and refuses rather than deadlocking.
65pub(crate) fn on_facility_thread() -> bool {
66    ON_FACILITY_THREAD.with(Cell::get)
67}
68
69/// Marks the calling thread a facility worker until it is dropped.
70struct FacilityThreadMark(bool);
71
72impl FacilityThreadMark {
73    fn set() -> Self {
74        FacilityThreadMark(ON_FACILITY_THREAD.with(|f| f.replace(true)))
75    }
76}
77
78impl Drop for FacilityThreadMark {
79    fn drop(&mut self) {
80        ON_FACILITY_THREAD.with(|f| f.set(self.0));
81    }
82}
83
84/// Latch for the poison announcement below: set on the first recovery.
85///
86/// Announced once per process, not once per recovery — poisoning is sticky, so
87/// every later lock in that facility would repeat it forever on a console that
88/// on RTEMS is a serial line.
89static POISON_ANNOUNCED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
90
91/// True exactly once per process — for the first poisoned lock recovered.
92fn poison_should_be_announced() -> bool {
93    !POISON_ANNOUNCED.swap(true, std::sync::atomic::Ordering::Relaxed)
94}
95
96/// Take a lock whose data outlives a panic — the only lock accessor these
97/// facilities use.
98///
99/// Every queue behind these mutexes is a `VecDeque`/`BinaryHeap` of pending
100/// work plus a `bool`; none has an invariant that a panic elsewhere could have
101/// broken. Recovering is therefore not a shortcut around poisoning, it is the
102/// correct reading of it.
103///
104/// Recovering is not silent, or a degraded IOC would read exactly like a
105/// healthy one. The panic that *caused* the poisoning is announced where it
106/// happened ([`run_isolated`], `run_facility_loop`) for the two paths that
107/// run on a facility thread; a panic under the lock on a *submitter's* thread
108/// — inside `schedule`/`request`, on whatever thread called it — has no such
109/// site, and this is what reports it.
110pub fn recover<T>(facility: &str, result: std::sync::LockResult<T>) -> T {
111    match result {
112        Ok(guard) => guard,
113        Err(poisoned) => {
114            if poison_should_be_announced() {
115                errlog_sev_printf(
116                    ErrlogSevEnum::Minor,
117                    &format!(
118                        "{facility}: recovered a lock poisoned by a panic elsewhere; \
119                         the queued work is intact and the facility continues. Later \
120                         recoveries are not repeated."
121                    ),
122                );
123            }
124            poisoned.into_inner()
125        }
126    }
127}
128
129/// Run one caller-supplied callback on a facility thread so that a panic
130/// inside it costs that callback and nothing else.
131///
132/// Returns `false` when the callback unwound.
133pub fn run_isolated(facility: &str, cb: impl FnOnce()) -> bool {
134    match catch_unwind(AssertUnwindSafe(cb)) {
135        Ok(()) => true,
136        Err(payload) => {
137            errlog_sev_printf(
138                ErrlogSevEnum::Major,
139                &format!(
140                    "{facility}: a callback panicked ({}); the callback was abandoned, \
141                     the facility continues",
142                    panic_text(&*payload)
143                ),
144            );
145            false
146        }
147    }
148}
149
150/// Run a facility's worker loop, announcing the facility's loss if the loop
151/// itself unwinds.
152///
153/// `on_lost` runs first and marks the facility stopped, so submitters take the
154/// same drop-late-requests path they take at shutdown instead of queueing onto
155/// a ring no thread will ever drain. Then the loss is stated — at `Fatal`,
156/// because from this point the facility is gone for the life of the process
157/// while everything around it still looks well.
158///
159/// This is also where the thread marks itself a facility worker
160/// ([`on_facility_thread`]): every worker loop already comes through here, so
161/// no facility — present or future — can acquire a worker thread that has not
162/// been marked. The mark covers the loop's dynamic extent and is restored on
163/// the way out (including on an unwind), so a caller that runs a loop inline on
164/// a borrowed thread does not leave that thread marked afterwards.
165pub(super) fn run_facility_loop(facility: &str, body: impl FnOnce(), on_lost: impl FnOnce()) {
166    let _marked = FacilityThreadMark::set();
167    if let Err(payload) = catch_unwind(AssertUnwindSafe(body)) {
168        on_lost();
169        errlog_sev_printf(
170            ErrlogSevEnum::Fatal,
171            &format!(
172                "{facility}: the worker thread has STOPPED ({}). Every operation this \
173                 facility carries has stopped with it; the IOC keeps serving requests.",
174                panic_text(&*payload)
175            ),
176        );
177    }
178}
179
180/// The panic message, when the payload is one of the two shapes `panic!`
181/// produces.
182fn panic_text(payload: &(dyn Any + Send)) -> &str {
183    if let Some(s) = payload.downcast_ref::<&'static str>() {
184        s
185    } else if let Some(s) = payload.downcast_ref::<String>() {
186        s.as_str()
187    } else {
188        "non-string panic payload"
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use std::sync::atomic::{AtomicBool, Ordering};
196
197    #[test]
198    fn a_poisoned_lock_still_yields_its_data() {
199        let m = std::sync::Mutex::new(7u32);
200        let _ = catch_unwind(AssertUnwindSafe(|| {
201            let _g = m.lock().expect("first lock");
202            panic!("poison it");
203        }));
204        assert!(m.lock().is_err(), "the mutex must actually be poisoned");
205        assert_eq!(
206            *recover("test-facility", m.lock()),
207            7,
208            "the data survived the panic"
209        );
210    }
211
212    #[test]
213    fn an_unwinding_callback_is_contained_and_reported() {
214        assert!(!run_isolated("test-facility", || panic!(
215            "callback blew up"
216        )));
217        assert!(run_isolated("test-facility", || {}));
218    }
219
220    #[test]
221    fn a_lost_loop_marks_the_facility_before_it_announces() {
222        let marked = AtomicBool::new(false);
223        run_facility_loop(
224            "test-facility",
225            || panic!("loop blew up"),
226            || marked.store(true, Ordering::SeqCst),
227        );
228        assert!(
229            marked.load(Ordering::SeqCst),
230            "a lost facility must be marked stopped, or submitters queue into a \
231             ring with no worker"
232        );
233    }
234
235    #[test]
236    fn a_loop_that_returns_normally_is_not_announced() {
237        let marked = AtomicBool::new(false);
238        run_facility_loop(
239            "test-facility",
240            || {},
241            || marked.store(true, Ordering::SeqCst),
242        );
243        assert!(
244            !marked.load(Ordering::SeqCst),
245            "an orderly shutdown is not a loss"
246        );
247    }
248
249    /// The treatment is uniform across the three facilities, or it is not a
250    /// treatment. A `.lock().unwrap()` reintroduced in any of them is one
251    /// caller's panic away from taking that facility down.
252    #[test]
253    fn no_facility_propagates_a_poisoned_lock() {
254        let files = [
255            (
256                "delayed_timer.rs",
257                include_str!("delayed_timer.rs"),
258                "delayed-callback timer",
259            ),
260            (
261                "scan_once.rs",
262                include_str!("scan_once.rs"),
263                "scanOnce worker",
264            ),
265            (
266                "callback_executor.rs",
267                include_str!("callback_executor.rs"),
268                "callback band",
269            ),
270        ];
271
272        // Split so this file's own needles do not match when it is read.
273        let poison = concat!(".lock()", ".unwrap()");
274        let wait_poison = concat!(".wait(", "st).unwrap()");
275
276        let mut offences = Vec::new();
277        for (label, src, facility) in files {
278            let prod = match src.find("\n#[cfg(test)]") {
279                Some(i) => &src[..i],
280                None => src,
281            };
282            for (n, line) in prod.lines().enumerate() {
283                if line.trim_start().starts_with("//") {
284                    continue;
285                }
286                if line.contains(poison) || line.contains(wait_poison) {
287                    offences.push(format!("{label}:{}", n + 1));
288                }
289            }
290            assert!(
291                prod.contains("run_facility_loop("),
292                "{label} does not guard its worker loop: its loss would be silent, \
293                 and its thread would not be marked a facility worker — so \
294                 `block_on_sync` would hand it a blocking bridge that parks the \
295                 facility"
296            );
297            assert!(
298                prod.contains("run_isolated("),
299                "{label} runs a caller's callback unprotected on its own thread"
300            );
301            assert!(
302                prod.contains(facility),
303                "{label} must name itself ({facility:?}) in what it reports"
304            );
305        }
306
307        assert!(
308            offences.is_empty(),
309            "these lock sites propagate poisoning instead of recovering: {offences:?}"
310        );
311    }
312
313    /// A recovered poisoning is stated once and then not again — a degraded
314    /// facility must not read as a healthy one, and must not fill a serial
315    /// console either.
316    #[test]
317    #[serial_test::serial]
318    fn the_first_poison_recovery_is_the_one_announced() {
319        POISON_ANNOUNCED.store(false, Ordering::SeqCst);
320        assert!(poison_should_be_announced(), "the first is announced");
321        assert!(!poison_should_be_announced(), "the second is not");
322        assert!(!poison_should_be_announced(), "nor any later one");
323    }
324
325    #[test]
326    fn the_panic_message_survives_into_the_report() {
327        let payload = catch_unwind(|| panic!("a formatted {}", "message")).expect_err("panics");
328        assert_eq!(panic_text(&*payload), "a formatted message");
329        let payload = catch_unwind(|| panic!("a literal")).expect_err("panics");
330        assert_eq!(panic_text(&*payload), "a literal");
331    }
332}