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