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}