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}