Skip to main content

epics_libcom_rs/runtime/
log.rs

1//! Runtime logging — `errlog` severity surface plus the `rt_*` macros.
2//!
3//! C parity: `modules/libcom/src/error/errlog.{c,h}`.
4//!
5//! The four `rt_*` macros route through the `tracing` facade (the
6//! crate's de-facto logging path) instead of bare `eprintln!`, so an
7//! application's `tracing` subscriber controls level filtering,
8//! formatting, and sinks uniformly.
9//!
10//! The `errlog`-severity API mirrors `errlogSevEnum`,
11//! `errlogSevEnumString`, `errlogSetSevToLog`/`errlogGetSevToLog`, and
12//! `errlogSevPrintf`. Note what C does *not* do: `errlogSevVprintf`
13//! (`errlog.c:379-391`) consults no threshold, and nothing else in base
14//! reads `pvt.sevToLog` either — `errlogSetSevToLog`/`errlogGetSevToLog`
15//! are a stored setting and no more. A C IOC therefore prints every
16//! `errlogSevPrintf` line whatever the setting says, and so does this.
17
18use std::sync::atomic::{AtomicU8, Ordering};
19
20/// True when no `tracing` subscriber would take an event — i.e. when
21/// everything this module emits is being discarded.
22///
23/// Every diagnostic in this workspace funnels into `tracing`, and installing a
24/// subscriber is the *application's* job. The hosted binaries do it; the RTEMS
25/// IOC entry points do not, because `tracing-subscriber` sits behind an
26/// optional feature that also drags in a Prometheus exporter. The result on
27/// target was an IOC that emitted **nothing at all** on its console — not a
28/// quiet IOC, a mute one, with every `errlog` line dropped on the floor.
29///
30/// C cannot reach that state: `errlogPrintf` ends at the console writer, which
31/// always exists. This restores that property without duplicating output when
32/// a subscriber *is* installed.
33///
34/// [`tracing::level_filters::LevelFilter::current`] reads the active
35/// dispatcher's max-level hint and is `OFF` exactly when there is no
36/// dispatcher (or one that has declared it wants nothing). It sees a
37/// scoped `with_default` subscriber as well as a global one, so a test that
38/// captures events does not also get console noise.
39fn nothing_is_listening() -> bool {
40    tracing::level_filters::LevelFilter::current() == tracing::level_filters::LevelFilter::OFF
41}
42
43/// Write one already-formatted errlog line to the console, but only when
44/// [`nothing_is_listening`].
45fn console_fallback(line: &std::fmt::Arguments<'_>) {
46    if nothing_is_listening() {
47        eprintln!("{line}");
48    }
49}
50
51/// A `tracing` subscriber that writes events to the console and nothing else.
52///
53/// Deliberately not `tracing_subscriber::fmt`: that crate is an optional
54/// dependency here, it pulls a Prometheus exporter along with it in the
55/// dependents that enable it, and none of what it adds — span storage, env
56/// filters, ANSI, timestamps off a clock that is quantised to whole seconds on
57/// RTEMS — is wanted on an IOC console. What is wanted is C's property: a
58/// diagnostic reaches the console.
59struct ConsoleSubscriber;
60
61/// Renders one event as `LEVEL target: message key=value …`.
62struct ConsoleLine {
63    out: String,
64    wrote_message: bool,
65}
66
67impl tracing::field::Visit for ConsoleLine {
68    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
69        use std::fmt::Write;
70        if field.name() == "message" {
71            // The message field arrives as `format_args!`, whose `Debug` is its
72            // `Display` — so this is the text, not a quoted rendering of it.
73            let _ = write!(self.out, "{value:?}");
74            self.wrote_message = true;
75        } else {
76            let _ = write!(
77                self.out,
78                "{}{}={value:?}",
79                if self.wrote_message { " " } else { "" },
80                field.name()
81            );
82            self.wrote_message = true;
83        }
84    }
85}
86
87/// `LEVEL target: message key=value …` — the one place an event becomes text.
88fn render_event(event: &tracing::Event<'_>) -> String {
89    let meta = event.metadata();
90    let mut line = ConsoleLine {
91        out: format!("{:<5} {}: ", meta.level(), meta.target()),
92        wrote_message: false,
93    };
94    event.record(&mut line);
95    line.out
96}
97
98impl tracing::Subscriber for ConsoleSubscriber {
99    fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
100        *metadata.level() <= tracing::Level::INFO
101    }
102
103    /// Declared so [`nothing_is_listening`] is false once this is installed —
104    /// without it the `errlog` console fallback would double every line.
105    fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
106        Some(tracing::level_filters::LevelFilter::INFO)
107    }
108
109    fn event(&self, event: &tracing::Event<'_>) {
110        eprintln!("{}", render_event(event));
111    }
112
113    // Spans are not rendered: this crate's diagnostics are events, and storing
114    // span data would be the one part of this that needs allocation per span.
115    fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
116        tracing::span::Id::from_u64(1)
117    }
118    fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
119    fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
120    fn enter(&self, _span: &tracing::span::Id) {}
121    fn exit(&self, _span: &tracing::span::Id) {}
122}
123
124/// Make this process's diagnostics reach the console, if nothing else has.
125///
126/// Every diagnostic in this workspace — `errlog`, the `rt_*` macros, and the
127/// `tracing::{warn,error,info}!` calls in the CA and PVA servers — funnels into
128/// `tracing`, and an event with no subscriber installed is *discarded*, not
129/// buffered. An IOC binary that never installs one is therefore mute: measured
130/// on target, a CA server refusing clients at its memory ceiling produced no
131/// console output of any kind, which is indistinguishable from a network fault.
132///
133/// C has no such state. `errlogPrintf` and `epicsPrintf` end at a console
134/// writer that always exists, so an IOC that is running always says so. This is
135/// the entry point that restores that property, and it belongs in the binary
136/// rather than in a library: installing a global subscriber is a whole-process
137/// decision, and a hosted application that installs its own must win.
138///
139/// Returns `false` when a subscriber was already installed — the caller's own
140/// choice takes precedence and nothing is changed.
141pub fn install_console_subscriber() -> bool {
142    tracing::subscriber::set_global_default(ConsoleSubscriber).is_ok()
143}
144
145/// Set once by [`install_panic_hook`], so a second call cannot chain the hook
146/// onto itself and print every panic twice.
147static PANIC_HOOK_INSTALLED: std::sync::atomic::AtomicBool =
148    std::sync::atomic::AtomicBool::new(false);
149
150/// One line saying what a panic on this thread costs the IOC.
151///
152/// A function, and a pure one, because the *consequence* is the part `std`'s
153/// default hook does not print and the part nobody can infer from a serial
154/// console. `std` says a thread panicked and where; it does not say whether the
155/// IOC is still serving.
156///
157/// The two arms are genuinely different outcomes on the target. The RTEMS build
158/// defaults to `panic = "unwind"`, so:
159///
160/// * on the entry thread the unwind leaves `main`, and the image is finished;
161/// * on any other thread — a CA client thread, a PVA connection thread, the
162///   status pusher — only that thread dies. The IOC keeps listening, keeps
163///   answering searches, and quietly no longer does whatever that thread did.
164///   That is the state this line exists to make visible, because it looks
165///   exactly like a healthy IOC from outside.
166fn panic_announcement(thread: Option<&str>, location: &str, payload: &str) -> String {
167    let thread = thread.unwrap_or("<unnamed>");
168    let consequence = if thread == "main" {
169        "the IOC's entry thread is unwinding: the image is going down, and every \
170         connection it serves with it"
171    } else {
172        "that thread is gone and nothing restarts it; the IOC keeps listening and \
173         keeps answering searches, so from outside it still looks healthy"
174    };
175    format!("panic on thread `{thread}` at {location}: {payload} -- {consequence}")
176}
177
178/// The panic payload as text — the message a `panic!`/`assert!` carried.
179fn panic_payload(info: &std::panic::PanicHookInfo<'_>) -> String {
180    if let Some(s) = info.payload().downcast_ref::<&str>() {
181        (*s).to_string()
182    } else if let Some(s) = info.payload().downcast_ref::<String>() {
183        s.clone()
184    } else {
185        "<non-string panic payload>".to_string()
186    }
187}
188
189/// Route panics through `errlog`, in addition to whatever `std` already does.
190///
191/// Call it once, in an IOC's `main`, next to [`install_console_subscriber`].
192///
193/// # Why an IOC needs this and a program does not
194///
195/// `std`'s default hook writes to stderr, which on the target is the serial
196/// console, so a panic is not *invisible* without this. Two things are missing
197/// from it, and both matter more on an IOC than in a program:
198///
199/// 1. **It says nothing about what still works.** A panic on a per-connection
200///    thread kills that thread and leaves the IOC listening, answering searches
201///    and serving every other client — indistinguishable from health, from
202///    outside, forever. The line this emits states which of the two outcomes
203///    this was.
204/// 2. **It is not on the errlog.** Every other diagnostic an IOC produces goes
205///    through `errlog`, and a panic is the most severe thing that can happen to
206///    one. Routing it there puts it in the same stream, at
207///    [`ErrlogSevEnum::Fatal`], for whatever is reading that stream.
208///
209/// # It replaces rather than chains
210///
211/// This used to run `std`'s default hook after its own line, on the reasoning
212/// that installing it could then only *add* output. On the target that
213/// reasoning does not hold, for three measured reasons:
214///
215/// 1. **The output would be doubled.** The line below already carries the
216///    thread, the panic site and the payload — everything the default hook
217///    prints — so chaining puts the same panic on the console twice. It reaches
218///    the console either way: with [`install_console_subscriber`] in place the
219///    subscriber writes it, and with nothing listening the `errlog` console
220///    fallback does.
221/// 2. **The `RUST_BACKTRACE` note is advice that cannot be taken.** There is no
222///    environment on the target to set that variable in, so a backtrace is off
223///    by construction; printing "run with `RUST_BACKTRACE=1`" on a serial
224///    console tells an operator to do something impossible.
225/// 3. **The panic path must stay shallow.** The default hook's formatting and
226///    backtrace machinery is stack the panic path does not otherwise need, and
227///    the per-connection stack ceiling is the thing currently being measured on
228///    the target. A hook must not be what makes the panic path deeper than the
229///    peak that measurement is establishing.
230///
231/// The consequence for a hosted build is deliberate and worth stating: a
232/// process that calls this gives up `std`'s backtrace-on-panic for the one line
233/// below. A host application that wants the backtrace should not install this
234/// hook — it is written for an image with no environment and no debugger.
235///
236/// Returns `false` when it was already installed, having changed nothing.
237pub fn install_panic_hook() -> bool {
238    use std::sync::atomic::Ordering as AtomicOrdering;
239    if PANIC_HOOK_INSTALLED.swap(true, AtomicOrdering::AcqRel) {
240        return false;
241    }
242    std::panic::set_hook(Box::new(|info| {
243        let location = match info.location() {
244            Some(l) => format!("{}:{}", l.file(), l.line()),
245            None => "an unknown location".to_string(),
246        };
247        let thread = std::thread::current();
248        errlog_sev_printf(
249            ErrlogSevEnum::Fatal,
250            &panic_announcement(thread.name(), &location, &panic_payload(info)),
251        );
252    }));
253    true
254}
255
256/// Error-message severity — C `errlogSevEnum` (`errlog.h:49-53`).
257///
258/// Ordered `Info < Minor < Major < Fatal`; the discriminants match the
259/// C enum values so they can be compared as the C code does.
260#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
261#[repr(u8)]
262pub enum ErrlogSevEnum {
263    /// `errlogInfo` = 0.
264    Info = 0,
265    /// `errlogMinor` = 1.
266    Minor = 1,
267    /// `errlogMajor` = 2.
268    Major = 2,
269    /// `errlogFatal` = 3.
270    Fatal = 3,
271}
272
273impl ErrlogSevEnum {
274    /// String form — C `errlogSevEnumString` (`errlog.h:60-65`).
275    pub fn as_str(self) -> &'static str {
276        match self {
277            ErrlogSevEnum::Info => "info",
278            ErrlogSevEnum::Minor => "minor",
279            ErrlogSevEnum::Major => "major",
280            ErrlogSevEnum::Fatal => "fatal",
281        }
282    }
283
284    fn from_u8(v: u8) -> ErrlogSevEnum {
285        match v {
286            0 => ErrlogSevEnum::Info,
287            1 => ErrlogSevEnum::Minor,
288            2 => ErrlogSevEnum::Major,
289            _ => ErrlogSevEnum::Fatal,
290        }
291    }
292}
293
294/// String representation of an errlog severity.
295///
296/// C parity: `errlogGetSevEnumString` (`errlog.c:391-397`) — an
297/// out-of-range value yields `"unknown"`; the typed Rust enum cannot be
298/// out of range, so this always maps to a real name.
299pub fn errlog_sev_enum_string(severity: ErrlogSevEnum) -> &'static str {
300    severity.as_str()
301}
302
303/// Backing store for `errlogSetSevToLog`/`errlogGetSevToLog`. C parity:
304/// `pvt.sevToLog` (`errlog.c:99`), which lives in the file-scope static
305/// `pvt` and is therefore zero-initialised — `errlogInfo`, not
306/// `errlogMinor`. Nothing may consult it; see [`errlog_set_sev_to_log`].
307static SEV_TO_LOG: AtomicU8 = AtomicU8::new(ErrlogSevEnum::Info as u8);
308
309/// Store the severity-to-log setting — C `errlogSetSevToLog`
310/// (`errlog.c:402-408`).
311///
312/// This sets a value and changes no behaviour, which is exactly what the
313/// C call does: `rg sevToLog` over base finds the struct member
314/// (`errlog.c:99`), this store (`:406`) and the read-back in
315/// [`errlog_get_sev_to_log`] (`:415`) and nothing else, so no message is
316/// ever filtered by it. Do not add a suppression test against this value
317/// — that would drop lines a C IOC prints.
318pub fn errlog_set_sev_to_log(severity: ErrlogSevEnum) {
319    SEV_TO_LOG.store(severity as u8, Ordering::Relaxed);
320}
321
322/// Read the severity-to-log setting back — C `errlogGetSevToLog`
323/// (`errlog.c:410-418`). Round-trips [`errlog_set_sev_to_log`]; carries
324/// no filtering authority.
325pub fn errlog_get_sev_to_log() -> ErrlogSevEnum {
326    ErrlogSevEnum::from_u8(SEV_TO_LOG.load(Ordering::Relaxed))
327}
328
329/// C `ERL_WARNING` (`errlog.h:299`) — the word an errlog warning line carries,
330/// magenta on a terminal console and plain everywhere else.
331///
332/// C spells it `ANSI_MAGENTA("WARNING")`, i.e. the escapes are always IN the
333/// message, and errlog strips them at print time when its console is not a
334/// terminal (`errlog.c:672-681`, `pvt.ttyConsole = isATTY(stderr)` at
335/// `errlog.c:555`). `isATTY` (`errlog.c:218-237`) also demands a non-empty
336/// `$TERM`, on the grounds that a terminal that will not name itself cannot be
337/// assumed to understand escapes. Both halves of that rule are here, so an
338/// `epicsEnvSet`-style capture of a Rust IOC's stderr gets the same bytes as C's.
339///
340/// Verified head-to-head with the compiled `softIoc` (bind-conflict warning):
341/// redirected to a file it writes `cas WARNING: …`; under `script(1)` it writes
342/// `cas \x1b[35;1mWARNING\x1b[0m: …`.
343pub fn erl_warning() -> &'static str {
344    use std::io::IsTerminal;
345    let term_names_itself = std::env::var_os("TERM").is_some_and(|t| !t.is_empty());
346    if std::io::stderr().is_terminal() && term_names_itself {
347        "\x1b[35;1mWARNING\x1b[0m"
348    } else {
349        "WARNING"
350    }
351}
352
353/// Emit a pre-formatted error message at the given severity.
354///
355/// C parity: `errlogSevVprintf`/`errlogSevPrintf` (`errlog.c:371-391`)
356/// — the C code prefixes `"sevr=%s "` and routes to the message queue,
357/// unconditionally. Here the prefix is preserved and the message is
358/// routed through `tracing` at a level mapped from the severity.
359///
360/// Unconditionally is the whole point: `errlogSevVprintf` tests no
361/// threshold, so an `errlogInfo` line reaches a C console whatever
362/// `errlogSetSevToLog` was told. See [`errlog_set_sev_to_log`].
363pub fn errlog_sev_printf(severity: ErrlogSevEnum, message: &str) {
364    let line = format!("sevr={} {}", severity.as_str(), message);
365    match severity {
366        ErrlogSevEnum::Info => {
367            tracing::info!(target: "epics_base_rs::errlog", "{line}")
368        }
369        ErrlogSevEnum::Minor => {
370            tracing::warn!(target: "epics_base_rs::errlog", "{line}")
371        }
372        ErrlogSevEnum::Major | ErrlogSevEnum::Fatal => {
373            tracing::error!(target: "epics_base_rs::errlog", "{line}")
374        }
375    }
376    console_fallback(&format_args!("{line}"));
377}
378
379/// Emit a pre-formatted message through the errlog facility
380/// unconditionally — C `errlogVprintf`/`errlogPrintf`
381/// (`errlog.c:333-364`), the *no-severity* variant.
382///
383/// Unlike [`errlog_sev_printf`] this carries no `sevr=` prefix (C
384/// `errlogVprintf` enqueues the caller's bytes verbatim). Neither call
385/// is gated: see [`errlog_set_sev_to_log`]. Routed through `tracing` at info
386/// level on the same `epics_base_rs::errlog` target, so an application's
387/// subscriber sees it on the errlog sink. Used by `stdio` device support
388/// for the `"errlog"` output stream (`devStdio.c` `logPrintf`).
389pub fn errlog_printf(message: &str) {
390    tracing::info!(target: "epics_base_rs::errlog", "{message}");
391    console_fallback(&format_args!("{message}"));
392}
393
394/// Debug-level runtime log line. Routes through the `tracing` facade.
395#[macro_export]
396macro_rules! rt_debug {
397    ($($arg:tt)*) => {
398        ::tracing::debug!(target: "epics_base_rs::runtime", "{}", format!($($arg)*));
399    };
400}
401
402/// Info-level runtime log line. Routes through the `tracing` facade.
403#[macro_export]
404macro_rules! rt_info {
405    ($($arg:tt)*) => {
406        ::tracing::info!(target: "epics_base_rs::runtime", "{}", format!($($arg)*));
407    };
408}
409
410/// Warn-level runtime log line. Routes through the `tracing` facade.
411#[macro_export]
412macro_rules! rt_warn {
413    ($($arg:tt)*) => {
414        ::tracing::warn!(target: "epics_base_rs::runtime", "{}", format!($($arg)*));
415    };
416}
417
418/// Error-level runtime log line. Routes through the `tracing` facade.
419#[macro_export]
420macro_rules! rt_error {
421    ($($arg:tt)*) => {
422        ::tracing::error!(target: "epics_base_rs::runtime", "{}", format!($($arg)*));
423    };
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use serial_test::serial;
430
431    #[test]
432    fn test_log_macros_compile() {
433        rt_debug!("debug message {}", 42);
434        rt_info!("info message");
435        rt_warn!("warn: {}", "something");
436        rt_error!("error: {} {}", "bad", "thing");
437    }
438
439    /// The condition the console fallback keys on. With no subscriber the
440    /// `tracing` dispatcher reports `OFF`, and every errlog line in the
441    /// process is being discarded — the state each RTEMS IOC binary runs in,
442    /// because installing a subscriber is the application's job and those
443    /// entry points do not do it (`tracing-subscriber` sits behind an
444    /// optional feature that also pulls a Prometheus exporter).
445    #[test]
446    #[serial]
447    fn with_no_subscriber_nothing_is_listening() {
448        assert!(
449            nothing_is_listening(),
450            "the test process has no global subscriber, so errlog output is \
451             being discarded and the console fallback must engage"
452        );
453    }
454
455    /// …and with one installed the fallback must stand down, or every hosted
456    /// IOC gets each errlog line twice: once through its own sink and once on
457    /// stderr.
458    #[test]
459    #[serial]
460    fn with_a_subscriber_the_fallback_stands_down() {
461        use tracing::subscriber::with_default;
462        let captured = with_default(tracing_subscriber::registry(), nothing_is_listening);
463        assert!(
464            !captured,
465            "a scoped subscriber is listening, so the console fallback must not fire"
466        );
467    }
468
469    /// The console subscriber must be one of the subscribers that stands the
470    /// fallback down. It is not automatic: a `Subscriber` whose
471    /// `max_level_hint` is left at the default reports no hint, and this file's
472    /// own fallback would then print every errlog line a second time on the
473    /// very target the subscriber exists for.
474    #[test]
475    #[serial]
476    fn the_console_subscriber_declares_itself_to_the_dispatcher() {
477        use tracing::level_filters::LevelFilter;
478        use tracing::subscriber::with_default;
479
480        let (still_mute, level) = with_default(ConsoleSubscriber, || {
481            (nothing_is_listening(), LevelFilter::current())
482        });
483        assert!(
484            !still_mute,
485            "the console subscriber is listening, so the errlog fallback must \
486             not also print — that is every line twice"
487        );
488        assert_eq!(
489            level,
490            LevelFilter::INFO,
491            "the console takes INFO and above, matching a C IOC's errlog console"
492        );
493    }
494
495    /// A capturing stand-in that shares the console's rendering. What it
496    /// asserts is [`render_event`], which is the whole of what the console
497    /// subscriber does with an event.
498    struct CapturingSubscriber(std::sync::Arc<std::sync::Mutex<Vec<String>>>);
499
500    impl tracing::Subscriber for CapturingSubscriber {
501        fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
502            *metadata.level() <= tracing::Level::INFO
503        }
504        fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
505            Some(tracing::level_filters::LevelFilter::INFO)
506        }
507        fn event(&self, event: &tracing::Event<'_>) {
508            self.0.lock().expect("sink").push(render_event(event));
509        }
510        fn new_span(&self, _s: &tracing::span::Attributes<'_>) -> tracing::span::Id {
511            tracing::span::Id::from_u64(1)
512        }
513        fn record(&self, _s: &tracing::span::Id, _v: &tracing::span::Record<'_>) {}
514        fn record_follows_from(&self, _s: &tracing::span::Id, _f: &tracing::span::Id) {}
515        fn enter(&self, _s: &tracing::span::Id) {}
516        fn exit(&self, _s: &tracing::span::Id) {}
517    }
518
519    /// The rendered line carries the message *unquoted* and the structured
520    /// fields as `key=value`. The message field arrives as `format_args!`, so
521    /// rendering it through `Debug` is what keeps it readable; switching to a
522    /// `record_str` arm would wrap every diagnostic in quotes.
523    #[test]
524    #[serial]
525    fn the_console_line_carries_message_and_fields() {
526        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
527        tracing::subscriber::with_default(CapturingSubscriber(seen.clone()), || {
528            tracing::warn!(target: "epics_base_rs::test", nth = 7, "refused a client");
529            tracing::debug!(target: "epics_base_rs::test", "not at console level");
530        });
531
532        let lines = seen.lock().expect("sink").clone();
533        assert_eq!(
534            lines,
535            vec!["WARN  epics_base_rs::test: refused a client nth=7".to_string()],
536            "one INFO-or-above event, message unquoted, fields appended"
537        );
538    }
539
540    /// Below-INFO events must not reach the console — asserted above by the
541    /// `debug!` that produced no line, and here at the filter itself so the
542    /// reason is not mistaken for a rendering accident.
543    #[test]
544    #[serial]
545    fn the_console_subscriber_declines_below_info() {
546        use tracing::level_filters::LevelFilter;
547        let taken = tracing::subscriber::with_default(ConsoleSubscriber, || {
548            LevelFilter::current() >= LevelFilter::DEBUG
549        });
550        assert!(!taken, "DEBUG must be below the console's level");
551    }
552
553    #[test]
554    fn sev_enum_strings_match_c() {
555        // C `errlogSevEnumString` (errlog.h:60-65).
556        assert_eq!(errlog_sev_enum_string(ErrlogSevEnum::Info), "info");
557        assert_eq!(errlog_sev_enum_string(ErrlogSevEnum::Minor), "minor");
558        assert_eq!(errlog_sev_enum_string(ErrlogSevEnum::Major), "major");
559        assert_eq!(errlog_sev_enum_string(ErrlogSevEnum::Fatal), "fatal");
560    }
561
562    #[test]
563    fn sev_enum_ordering() {
564        assert!(ErrlogSevEnum::Info < ErrlogSevEnum::Minor);
565        assert!(ErrlogSevEnum::Minor < ErrlogSevEnum::Major);
566        assert!(ErrlogSevEnum::Major < ErrlogSevEnum::Fatal);
567    }
568
569    #[test]
570    #[serial(errlog_sev)]
571    fn sev_to_log_threshold_roundtrips() {
572        errlog_set_sev_to_log(ErrlogSevEnum::Major);
573        assert_eq!(errlog_get_sev_to_log(), ErrlogSevEnum::Major);
574        // Restore the C default.
575        errlog_set_sev_to_log(ErrlogSevEnum::Info);
576        assert_eq!(errlog_get_sev_to_log(), ErrlogSevEnum::Info);
577    }
578
579    /// C's `pvt` is a file-scope static, so `pvt.sevToLog` starts at 0 —
580    /// `errlogInfo`. Every test in the `errlog_sev` group restores that
581    /// value, so this holds whichever order they run in.
582    #[test]
583    #[serial(errlog_sev)]
584    fn sev_to_log_defaults_to_info_like_c_zero_init() {
585        assert_eq!(
586            errlog_get_sev_to_log(),
587            ErrlogSevEnum::Info,
588            "C zero-initialises pvt.sevToLog to errlogInfo, not errlogMinor"
589        );
590    }
591
592    /// The setting is inert. `errlogSevVprintf` (`errlog.c:379-391`) tests
593    /// no threshold, and no other C function reads `pvt.sevToLog`, so a
594    /// C IOC prints `sevr=info` lines even after `errlogSetSevToLog(major)`
595    /// — e.g. `devBiDbState`'s "Creating new db state" notice at iocInit.
596    #[test]
597    #[serial(errlog_sev)]
598    fn sev_printf_emits_every_severity_whatever_sev_to_log_says() {
599        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
600        errlog_set_sev_to_log(ErrlogSevEnum::Fatal);
601        tracing::subscriber::with_default(CapturingSubscriber(seen.clone()), || {
602            errlog_sev_printf(ErrlogSevEnum::Info, "creating new db state 'mystate'");
603            errlog_sev_printf(ErrlogSevEnum::Minor, "a minor complaint");
604            errlog_sev_printf(ErrlogSevEnum::Major, "a major complaint");
605        });
606        errlog_set_sev_to_log(ErrlogSevEnum::Info);
607
608        let lines = seen.lock().expect("sink").clone();
609        assert!(
610            lines
611                .iter()
612                .any(|l| l.contains("sevr=info creating new db state 'mystate'")),
613            "sevToLog=fatal must not suppress an errlogInfo line: {lines:?}"
614        );
615        assert!(
616            lines
617                .iter()
618                .any(|l| l.contains("sevr=minor a minor complaint")),
619            "sevToLog=fatal must not suppress an errlogMinor line: {lines:?}"
620        );
621        assert!(
622            lines
623                .iter()
624                .any(|l| l.contains("sevr=major a major complaint")),
625            "sevToLog=fatal must not suppress an errlogMajor line: {lines:?}"
626        );
627    }
628    /// The distinction the whole hook exists for. A panic on a worker thread
629    /// leaves an IOC that still listens and still answers searches, which is
630    /// indistinguishable from health from outside; the announcement has to say
631    /// so, because nothing else will.
632    #[test]
633    fn a_worker_panic_says_the_ioc_is_still_up_and_no_longer_whole() {
634        let line = panic_announcement(
635            Some("CAS-client-3"),
636            "blocking.rs:412",
637            "index out of bounds",
638        );
639        assert!(line.contains("CAS-client-3"), "{line}");
640        assert!(line.contains("blocking.rs:412"), "{line}");
641        assert!(line.contains("index out of bounds"), "{line}");
642        assert!(
643            line.contains("keeps listening"),
644            "a worker panic must say the IOC survives it, or the console reads \
645             like the IOC died when it did not: {line}"
646        );
647        assert!(
648            !line.contains("going down"),
649            "a worker panic must not claim the image is finished: {line}"
650        );
651    }
652
653    /// The other outcome, which is the opposite claim and must not be confused
654    /// with it: the RTEMS build unwinds, so a panic that leaves `main` ends the
655    /// image.
656    #[test]
657    fn an_entry_thread_panic_says_the_image_is_finished() {
658        let line = panic_announcement(Some("main"), "realtime-ca-ioc.rs:118", "iocInit failed");
659        assert!(
660            line.contains("going down"),
661            "a panic out of the entry thread ends the image, and the console is \
662             the only place that can say so: {line}"
663        );
664        assert!(!line.contains("keeps listening"), "{line}");
665    }
666
667    /// RTEMS threads that were not named through `thread::Builder` have no
668    /// name, and the line must still identify itself rather than render an
669    /// empty pair of backticks.
670    #[test]
671    fn an_unnamed_thread_is_still_named_something() {
672        let line = panic_announcement(None, "x.rs:1", "boom");
673        assert!(line.contains("<unnamed>"), "{line}");
674        assert!(
675            line.contains("keeps listening"),
676            "an unnamed thread is not the entry thread — std names that one \
677             `main` — so it takes the worker consequence: {line}"
678        );
679    }
680
681    /// Installing twice must not chain the hook onto itself: that prints every
682    /// panic once per install, and the second copy looks like a second panic.
683    ///
684    /// Restores the default hook afterwards so a `cargo test` run — which,
685    /// unlike `cargo nextest`, shares one process across tests — is not left
686    /// with this one.
687    #[test]
688    #[serial]
689    fn the_panic_hook_installs_once() {
690        assert!(install_panic_hook(), "the first install takes effect");
691        assert!(
692            !install_panic_hook(),
693            "a second install must be refused, not chained onto the first"
694        );
695        let _ = std::panic::take_hook();
696    }
697
698    /// The hook replaces the previous one; it does not run it afterwards.
699    ///
700    /// Chaining would print the panic twice — this hook's line already carries
701    /// the thread, site and payload — and would append `std`'s
702    /// "run with `RUST_BACKTRACE=1`" note, which on the target is advice for an
703    /// environment that does not exist. A sentinel hook proves the absence
704    /// directly: if the previous hook still ran, it would flip the flag.
705    #[test]
706    #[serial]
707    fn the_panic_hook_does_not_run_the_hook_it_replaced() {
708        use std::sync::Arc;
709        use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
710
711        let previous_ran = Arc::new(AtomicBool::new(false));
712        let flag = previous_ran.clone();
713        std::panic::set_hook(Box::new(move |_| {
714            flag.store(true, AtomicOrdering::SeqCst);
715        }));
716
717        assert!(install_panic_hook(), "the install takes effect");
718        let caught = std::panic::catch_unwind(|| panic!("a panic the hook must report once"));
719        assert!(caught.is_err(), "the panic was raised");
720
721        assert!(
722            !previous_ran.load(AtomicOrdering::SeqCst),
723            "the replaced hook must not run: chaining it doubles the console \
724             output and appends a RUST_BACKTRACE note that cannot be acted on"
725        );
726        let _ = std::panic::take_hook();
727    }
728}
729
730/// Render `record` so it cannot end or split a line in a line-oriented log.
731///
732/// Every ASCII control character — `0x00..=0x1F` (which includes `\n`, `\r`
733/// and NUL) and `0x7F` — becomes a printable `\xNN` escape. Everything else,
734/// including all non-ASCII UTF-8, is passed through untouched, so the common
735/// case allocates nothing.
736///
737/// # What this guarantees, and what it does not
738///
739/// It guarantees **line framing**: one record in, one line out, whatever the
740/// record contains. That is the property an audit log needs — a reader must
741/// not be able to mistake attacker-supplied text for a separate record.
742///
743/// It is deliberately **not** a reversible encoding: a backslash is left
744/// alone, so a user string containing the four literal characters `\x0a` and
745/// a real newline escape to the same bytes. Escaping backslashes would make
746/// it reversible but would also corrupt any record that is already escaped —
747/// a JSON record whose own encoder emitted `\n` would come back out as
748/// `\\n`. Leaving backslash alone is what makes this safe to apply uniformly
749/// at the writer, to every record, without the writer having to know which
750/// renderer produced it.
751///
752/// Applying it to already-escaped output is a no-op, because a correct
753/// encoder has already removed every raw control byte.
754pub fn single_line(record: &str) -> std::borrow::Cow<'_, str> {
755    fn must_escape(c: char) -> bool {
756        (c as u32) < 0x20 || c as u32 == 0x7F
757    }
758    if !record.contains(must_escape) {
759        return std::borrow::Cow::Borrowed(record);
760    }
761    let mut out = String::with_capacity(record.len() + 8);
762    for c in record.chars() {
763        if must_escape(c) {
764            use std::fmt::Write;
765            let _ = write!(out, "\\x{:02x}", c as u32);
766        } else {
767            out.push(c);
768        }
769    }
770    std::borrow::Cow::Owned(out)
771}
772
773#[cfg(test)]
774mod single_line_tests {
775    use super::single_line;
776
777    /// The framing guarantee, stated as a boundary sweep over every byte a
778    /// record could carry rather than as a story about one attack.
779    #[test]
780    fn no_input_can_produce_more_than_one_line() {
781        for b in 0u8..=0x7F {
782            let c = b as char;
783            let record = format!("a{c}b");
784            let out = single_line(&record);
785            assert_eq!(
786                out.lines().count().max(1),
787                1,
788                "byte {b:#04x} split the record: {out:?}"
789            );
790            assert!(!out.contains('\n'), "byte {b:#04x} left a newline");
791            assert!(!out.contains('\r'), "byte {b:#04x} left a carriage return");
792            assert!(!out.contains('\0'), "byte {b:#04x} left a NUL");
793        }
794    }
795
796    #[test]
797    fn exactly_the_ascii_control_range_is_escaped() {
798        for b in 0u8..=0xFF {
799            if b >= 0x80 {
800                continue; // non-ASCII is tested as UTF-8 below
801            }
802            let c = b as char;
803            let raw = c.to_string();
804            let out = single_line(&raw);
805            let escaped = out != raw;
806            assert_eq!(
807                escaped,
808                b < 0x20 || b == 0x7F,
809                "byte {b:#04x}: escaped={escaped}, expected={}",
810                b < 0x20 || b == 0x7F
811            );
812        }
813        assert_eq!(single_line("\n"), "\\x0a");
814        assert_eq!(single_line("\r"), "\\x0d");
815        assert_eq!(single_line("\0"), "\\x00");
816        assert_eq!(single_line("\u{7f}"), "\\x7f");
817    }
818
819    /// A clean record is returned borrowed — no allocation on the hot path.
820    #[test]
821    fn a_clean_record_is_passed_through_without_allocating() {
822        let clean = "Apr 09 14:35:21 alice@opi-1 TEMP:setpoint 3.14 old=? OK";
823        assert!(matches!(single_line(clean), std::borrow::Cow::Borrowed(_)));
824        assert_eq!(single_line(clean), clean);
825        // Non-ASCII survives intact: this escapes line framing, not Unicode.
826        assert_eq!(single_line("설정값 μm"), "설정값 μm");
827    }
828
829    /// Applying it to output that is already escaped must not corrupt it —
830    /// this is what lets the writer apply ONE rule to every renderer instead
831    /// of asking which renderer produced the record.
832    #[test]
833    fn it_is_a_no_op_on_already_escaped_output() {
834        let json = r#"{"user":"a\nb","pv":"X"}"#;
835        assert_eq!(single_line(json), json);
836        assert_eq!(single_line(&single_line("a\nb")), single_line("a\nb"));
837    }
838}