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}