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:376-388`) 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/// The `tracing` target every `errlog` entry point publishes on.
44///
45/// The `tracing` macros take a literal, so this constant cannot be used at the
46/// emit sites; `the_errlog_target_is_the_one_the_macros_publish_on` asserts the
47/// two spellings agree, so the subscriber's skip cannot drift from them.
48const ERRLOG_TARGET: &str = "epics_base_rs::errlog";
49
50/// True when the dispatcher that would take this event is this crate's own
51/// [`ConsoleSubscriber`] — i.e. when the errlog console is ours to write.
52///
53/// A fact about the process, not a guess: [`ConsoleSubscriber`] exists to give
54/// an IOC C's console, so when it is the one installed, C's bytes are what the
55/// console must get, and [`write_console`] is what writes them. An application
56/// that installed its OWN subscriber asked for its own formatting instead, and
57/// gets it — errlog stays a `tracing` event for exactly that reason.
58fn console_subscriber_is_current() -> bool {
59 tracing::dispatcher::get_default(|d| d.is::<ConsoleSubscriber>())
60}
61
62/// Write one already-formatted errlog line to the console, when this process's
63/// errlog console is ours and the `eltc` setting still says to.
64///
65/// The single owner of "does this line reach the console". C's owner is the
66/// errlog worker's `pvt.toConsole ? pvt.console : NULL` (`errlog.c:648`); here
67/// it is this function, called on the logging thread so that a scoped
68/// `tracing` subscriber and the caller's span still apply.
69///
70/// The console is ours in the two states an IOC runs in: nothing listening at
71/// all ([`nothing_is_listening`]), and [`ConsoleSubscriber`] installed — which
72/// then skips `epics_base_rs::errlog` events precisely so these bytes are
73/// written once, verbatim, instead of a second time behind a `LEVEL target:`
74/// prefix. Routing errlog's console through here rather than through the
75/// subscriber is also what keeps `errlogPrintfNoConsole` off the console and
76/// makes `eltc(0)` mean what C means by it: both are decisions about *these*
77/// bytes, and a `tracing` event carries neither.
78///
79/// `local_echo` is C's `msgbufCommit` argument, decided once per message by
80/// [`errlog_post`] under the queue lock. It is a parameter rather than another
81/// [`errlog_to_console`] call because the same answer also decides whether the
82/// producer waits for the drain: two reads of `eltc` would let one message
83/// print without back-pressure, or take back-pressure without printing.
84fn console_fallback(line: &str, local_echo: bool) {
85 if local_echo && (nothing_is_listening() || console_subscriber_is_current()) {
86 write_console(&mut std::io::stderr().lock(), line);
87 }
88}
89
90/// The one place errlog bytes become console bytes, and it adds nothing.
91///
92/// C's console writer is `fprintf(console, "%s", base+1u)` (`errlog.c:795`,
93/// and `:170` on the at-exit path): the caller's bytes and no terminator, so a
94/// C caller that wants a line break puts `\n` in its own format string and one
95/// that does not gets none. This does the same — `write_all`, never
96/// `eprintln!`. A console that appends its own newline gives a call site's
97/// bytes and the console's bytes two different meanings, and then no call site
98/// can be read against its C original: the ones that correctly carry `\n`
99/// print a blank line C does not print, and the ones that carry none are
100/// silently rescued.
101///
102/// The sink is a parameter so the framing can be asserted on a buffer; the
103/// process console is `stderr`, which is unbuffered, so C's `fflush` after a
104/// drain pass has no analogue to skip.
105fn write_console(out: &mut impl std::io::Write, line: &str) {
106 // C ignores `fprintf`'s return here too: a console that cannot be written
107 // is not something an errlog line can report.
108 let _ = out.write_all(line.as_bytes());
109}
110
111/// The same bytes as a `tracing` record, which is one event and not a byte
112/// stream.
113///
114/// A subscriber terminates an event itself, so handing it the caller's
115/// trailing newline as well puts a blank line after every errlog line. The
116/// console that shows is an *application's* formatter — `qsrv-rs`,
117/// `pva-gateway-rs`, `procserv-rs` and the example IOCs all install
118/// `tracing_subscriber::fmt` — and the capture tests that read the errlog sink
119/// through one. This crate's own [`ConsoleSubscriber`] no longer renders these
120/// events at all ([`ConsoleSubscriber::line_for`]); [`write_console`] writes
121/// C's bytes for it, so the two consoles do not disagree. Only the
122/// *trailing* newline is framing: the ones inside a multi-line message
123/// (`dbScan`'s over-run report, `iocBuild`'s two-line `asInit` failure) are
124/// content and stay.
125fn as_record(line: &str) -> &str {
126 line.strip_suffix('\n').unwrap_or(line)
127}
128
129/// A `tracing` subscriber that writes events to the console and nothing else.
130///
131/// Deliberately not `tracing_subscriber::fmt`: that crate is an optional
132/// dependency here, it pulls a Prometheus exporter along with it in the
133/// dependents that enable it, and none of what it adds — span storage, env
134/// filters, ANSI, timestamps off a clock that is quantised to whole seconds on
135/// RTEMS — is wanted on an IOC console. What is wanted is C's property: a
136/// diagnostic reaches the console.
137struct ConsoleSubscriber;
138
139impl ConsoleSubscriber {
140 /// The line this subscriber writes for `event`, or `None` when the event is
141 /// errlog's.
142 ///
143 /// An errlog event's bytes are C's, and [`write_console`] has already put
144 /// them on the console exactly as `fprintf(console, "%s", …)` does
145 /// (`errlog.c:795`). Rendering them here as well would print each line
146 /// twice, the second copy behind a `LEVEL target:` prefix C does not write
147 /// — and would re-frame a caller that deliberately composes one console
148 /// line out of several unterminated `errlogPrintf` calls, which is what C's
149 /// own `dumpInfo` does (`epicsStackTrace.c:46-57`).
150 fn line_for(event: &tracing::Event<'_>) -> Option<String> {
151 (event.metadata().target() != ERRLOG_TARGET).then(|| render_event(event))
152 }
153}
154
155/// Renders one event as `LEVEL target: message key=value …`.
156struct ConsoleLine {
157 out: String,
158 wrote_message: bool,
159}
160
161impl tracing::field::Visit for ConsoleLine {
162 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
163 use std::fmt::Write;
164 if field.name() == "message" {
165 // The message field arrives as `format_args!`, whose `Debug` is its
166 // `Display` — so this is the text, not a quoted rendering of it.
167 let _ = write!(self.out, "{value:?}");
168 self.wrote_message = true;
169 } else {
170 let _ = write!(
171 self.out,
172 "{}{}={value:?}",
173 if self.wrote_message { " " } else { "" },
174 field.name()
175 );
176 self.wrote_message = true;
177 }
178 }
179}
180
181/// `LEVEL target: message key=value …` — the one place an event becomes text.
182fn render_event(event: &tracing::Event<'_>) -> String {
183 let meta = event.metadata();
184 let mut line = ConsoleLine {
185 out: format!("{:<5} {}: ", meta.level(), meta.target()),
186 wrote_message: false,
187 };
188 event.record(&mut line);
189 line.out
190}
191
192impl tracing::Subscriber for ConsoleSubscriber {
193 fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
194 *metadata.level() <= tracing::Level::INFO
195 }
196
197 /// Declared so [`nothing_is_listening`] is false once this is installed —
198 /// without it the `errlog` console fallback would double every line.
199 fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
200 Some(tracing::level_filters::LevelFilter::INFO)
201 }
202
203 fn event(&self, event: &tracing::Event<'_>) {
204 if let Some(line) = Self::line_for(event) {
205 eprintln!("{line}");
206 }
207 }
208
209 // Spans are not rendered: this crate's diagnostics are events, and storing
210 // span data would be the one part of this that needs allocation per span.
211 fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
212 tracing::span::Id::from_u64(1)
213 }
214 fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
215 fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
216 fn enter(&self, _span: &tracing::span::Id) {}
217 fn exit(&self, _span: &tracing::span::Id) {}
218}
219
220/// Make this process's diagnostics reach the console, if nothing else has.
221///
222/// Every diagnostic in this workspace — `errlog`, the `rt_*` macros, and the
223/// `tracing::{warn,error,info}!` calls in the CA and PVA servers — funnels into
224/// `tracing`, and an event with no subscriber installed is *discarded*, not
225/// buffered. An IOC binary that never installs one is therefore mute: measured
226/// on target, a CA server refusing clients at its memory ceiling produced no
227/// console output of any kind, which is indistinguishable from a network fault.
228///
229/// C has no such state. `errlogPrintf` and `epicsPrintf` end at a console
230/// writer that always exists, so an IOC that is running always says so. This is
231/// the entry point that restores that property, and it belongs in the binary
232/// rather than in a library: installing a global subscriber is a whole-process
233/// decision, and a hosted application that installs its own must win.
234///
235/// Returns `false` when a subscriber was already installed — the caller's own
236/// choice takes precedence and nothing is changed.
237pub fn install_console_subscriber() -> bool {
238 tracing::subscriber::set_global_default(ConsoleSubscriber).is_ok()
239}
240
241/// Set once by [`install_panic_hook`], so a second call cannot chain the hook
242/// onto itself and print every panic twice.
243static PANIC_HOOK_INSTALLED: std::sync::atomic::AtomicBool =
244 std::sync::atomic::AtomicBool::new(false);
245
246/// One line saying what a panic on this thread costs the IOC.
247///
248/// A function, and a pure one, because the *consequence* is the part `std`'s
249/// default hook does not print and the part nobody can infer from a serial
250/// console. `std` says a thread panicked and where; it does not say whether the
251/// IOC is still serving.
252///
253/// The two arms are genuinely different outcomes on the target. The RTEMS build
254/// defaults to `panic = "unwind"`, so:
255///
256/// * on the entry thread the unwind leaves `main`, and the image is finished;
257/// * on any other thread — a CA client thread, a PVA connection thread, the
258/// status pusher — only that thread dies. The IOC keeps listening, keeps
259/// answering searches, and quietly no longer does whatever that thread did.
260/// That is the state this line exists to make visible, because it looks
261/// exactly like a healthy IOC from outside.
262fn panic_announcement(thread: Option<&str>, location: &str, payload: &str) -> String {
263 let thread = thread.unwrap_or("<unnamed>");
264 let consequence = if thread == "main" {
265 "the IOC's entry thread is unwinding: the image is going down, and every \
266 connection it serves with it"
267 } else {
268 "that thread is gone and nothing restarts it; the IOC keeps listening and \
269 keeps answering searches, so from outside it still looks healthy"
270 };
271 format!("panic on thread `{thread}` at {location}: {payload} -- {consequence}")
272}
273
274/// The panic payload as text — the message a `panic!`/`assert!` carried.
275fn panic_payload(info: &std::panic::PanicHookInfo<'_>) -> String {
276 if let Some(s) = info.payload().downcast_ref::<&str>() {
277 (*s).to_string()
278 } else if let Some(s) = info.payload().downcast_ref::<String>() {
279 s.clone()
280 } else {
281 "<non-string panic payload>".to_string()
282 }
283}
284
285/// Route panics through `errlog`, in addition to whatever `std` already does.
286///
287/// Call it once, in an IOC's `main`, next to [`install_console_subscriber`].
288///
289/// # Why an IOC needs this and a program does not
290///
291/// `std`'s default hook writes to stderr, which on the target is the serial
292/// console, so a panic is not *invisible* without this. Two things are missing
293/// from it, and both matter more on an IOC than in a program:
294///
295/// 1. **It says nothing about what still works.** A panic on a per-connection
296/// thread kills that thread and leaves the IOC listening, answering searches
297/// and serving every other client — indistinguishable from health, from
298/// outside, forever. The line this emits states which of the two outcomes
299/// this was.
300/// 2. **It is not on the errlog.** Every other diagnostic an IOC produces goes
301/// through `errlog`, and a panic is the most severe thing that can happen to
302/// one. Routing it there puts it in the same stream, at
303/// [`ErrlogSevEnum::Fatal`], for whatever is reading that stream.
304///
305/// # It replaces rather than chains
306///
307/// This used to run `std`'s default hook after its own line, on the reasoning
308/// that installing it could then only *add* output. On the target that
309/// reasoning does not hold, for three measured reasons:
310///
311/// 1. **The output would be doubled.** The line below already carries the
312/// thread, the panic site and the payload — everything the default hook
313/// prints — so chaining puts the same panic on the console twice. It reaches
314/// the console either way: `console_fallback` writes it with nothing
315/// listening and with [`install_console_subscriber`] in place alike, and an
316/// application that installed its own subscriber gets it through that.
317/// 2. **The `RUST_BACKTRACE` note is advice that cannot be taken.** There is no
318/// environment on the target to set that variable in, so a backtrace is off
319/// by construction; printing "run with `RUST_BACKTRACE=1`" on a serial
320/// console tells an operator to do something impossible.
321/// 3. **The panic path must stay shallow.** The default hook's formatting and
322/// backtrace machinery is stack the panic path does not otherwise need, and
323/// the per-connection stack ceiling is the thing currently being measured on
324/// the target. A hook must not be what makes the panic path deeper than the
325/// peak that measurement is establishing.
326///
327/// The consequence for a hosted build is deliberate and worth stating: a
328/// process that calls this gives up `std`'s backtrace-on-panic for the one line
329/// below. A host application that wants the backtrace should not install this
330/// hook — it is written for an image with no environment and no debugger.
331///
332/// Returns `false` when it was already installed, having changed nothing.
333pub fn install_panic_hook() -> bool {
334 use std::sync::atomic::Ordering as AtomicOrdering;
335 if PANIC_HOOK_INSTALLED.swap(true, AtomicOrdering::AcqRel) {
336 return false;
337 }
338 std::panic::set_hook(Box::new(|info| {
339 let location = match info.location() {
340 Some(l) => format!("{}:{}", l.file(), l.line()),
341 None => "an unknown location".to_string(),
342 };
343 let thread = std::thread::current();
344 errlog_sev_printf(
345 ErrlogSevEnum::Fatal,
346 // Terminated by the caller, as every C errlog format string is:
347 // the console writer appends nothing (see [`write_console`]).
348 &format!(
349 "{}\n",
350 panic_announcement(thread.name(), &location, &panic_payload(info))
351 ),
352 );
353 }));
354 true
355}
356
357/// Error-message severity — C `errlogSevEnum` (`errlog.h:49-53`).
358///
359/// Ordered `Info < Minor < Major < Fatal`; the discriminants match the
360/// C enum values so they can be compared as the C code does.
361#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
362#[repr(u8)]
363pub enum ErrlogSevEnum {
364 /// `errlogInfo` = 0.
365 Info = 0,
366 /// `errlogMinor` = 1.
367 Minor = 1,
368 /// `errlogMajor` = 2.
369 Major = 2,
370 /// `errlogFatal` = 3.
371 Fatal = 3,
372}
373
374impl ErrlogSevEnum {
375 /// String form — C `errlogSevEnumString` (`errlog.h:60-65`).
376 pub fn as_str(self) -> &'static str {
377 match self {
378 ErrlogSevEnum::Info => "info",
379 ErrlogSevEnum::Minor => "minor",
380 ErrlogSevEnum::Major => "major",
381 ErrlogSevEnum::Fatal => "fatal",
382 }
383 }
384
385 fn from_u8(v: u8) -> ErrlogSevEnum {
386 match v {
387 0 => ErrlogSevEnum::Info,
388 1 => ErrlogSevEnum::Minor,
389 2 => ErrlogSevEnum::Major,
390 _ => ErrlogSevEnum::Fatal,
391 }
392 }
393}
394
395/// String representation of an errlog severity.
396///
397/// C parity: `errlogGetSevEnumString` (`errlog.c:391-397`) — an
398/// out-of-range value yields `"unknown"`; the typed Rust enum cannot be
399/// out of range, so this always maps to a real name.
400pub fn errlog_sev_enum_string(severity: ErrlogSevEnum) -> &'static str {
401 severity.as_str()
402}
403
404/// Backing store for `errlogSetSevToLog`/`errlogGetSevToLog`. C parity:
405/// `pvt.sevToLog` (`errlog.c:96`), which lives in the file-scope static
406/// `pvt` and is therefore zero-initialised — `errlogInfo`, not
407/// `errlogMinor`. Nothing may consult it; see [`errlog_set_sev_to_log`].
408static SEV_TO_LOG: AtomicU8 = AtomicU8::new(ErrlogSevEnum::Info as u8);
409
410/// Store the severity-to-log setting — C `errlogSetSevToLog`
411/// (`errlog.c:399-405`).
412///
413/// This sets a value and changes no behaviour, which is exactly what the
414/// C call does: `rg sevToLog` over base finds the struct member
415/// (`errlog.c:96`), this store (`:403`) and the read-back in
416/// [`errlog_get_sev_to_log`] (`:412`) and nothing else, so no message is
417/// ever filtered by it. Do not add a suppression test against this value
418/// — that would drop lines a C IOC prints.
419pub fn errlog_set_sev_to_log(severity: ErrlogSevEnum) {
420 SEV_TO_LOG.store(severity as u8, Ordering::Relaxed);
421}
422
423/// Read the severity-to-log setting back — C `errlogGetSevToLog`
424/// (`errlog.c:407-415`). Round-trips [`errlog_set_sev_to_log`]; carries
425/// no filtering authority.
426pub fn errlog_get_sev_to_log() -> ErrlogSevEnum {
427 ErrlogSevEnum::from_u8(SEV_TO_LOG.load(Ordering::Relaxed))
428}
429
430/// C `ANSI_ESC_RED` (`errlog.h:281`) — the escape that opens a red bold span.
431///
432/// Present as a constant because two callers need the halves rather than the
433/// whole word: this module builds [`ERL_ERROR`] from them, and the iocsh error
434/// framer paints a message body the way C's own `showError` call sites do —
435/// they spell the format string `ANSI_RED(...)`, so the body arrives painted
436/// and the port must wrap it the same way.
437pub const ANSI_ESC_RED: &str = "\x1b[31;1m";
438
439/// C `ANSI_ESC_BOLD` (`errlog.h:287`) — opens a bold span.
440///
441/// Read by `iocsh`'s `format_help_entry`, which paints a command name the way
442/// C's `helpCallFunc` does, and by `softIoc`'s `verbose_out`
443/// (`softMain.cpp:58`), whose `CMD` colour this is.
444pub const ANSI_ESC_BOLD: &str = "\x1b[1m";
445
446/// C `ANSI_ESC_BLUE` (`errlog.h:284`) — opens a blue bold span.
447///
448/// `verbose_out`'s `REM` colour (`softMain.cpp:58`), and the colour `iocsh`
449/// echoes a script comment in.
450pub const ANSI_ESC_BLUE: &str = "\x1b[34;1m";
451
452/// C `ANSI_ESC_UNDERLINE` (`errlog.h:288`) — opens an underlined span.
453///
454/// Read by `iocsh`'s `format_help_entry` for an argument name.
455pub const ANSI_ESC_UNDERLINE: &str = "\x1b[4m";
456
457/// C `ANSI_ESC_RESET` (`errlog.h:289`) — closes any span above.
458pub const ANSI_ESC_RESET: &str = "\x1b[0m";
459
460// The remaining four of `errlog.h:281-288` — GREEN, YELLOW, MAGENTA, CYAN —
461// are deliberately absent: a constant nothing reads is a claim about C nobody
462// is checking. Green and magenta do appear in this workspace, but only inside
463// whole strings that are already correct and have no half-reader:
464// `IOCSH_PS1`'s compiled default (`ANSI_GREEN("epics> ")`) and [`ERL_WARNING`]
465// (`ANSI_MAGENTA("WARNING")`).
466
467/// C `ERL_ERROR` (`errlog.h:298`) — `ANSI_RED("ERROR")`, the severity word a
468/// diagnostic written straight to stderr carries.
469///
470/// Unconditional, and that is the whole distinction from [`erl_warning`]. C
471/// puts the escapes IN the message and strips them in one place only:
472/// `errlogStripANSI` sits inside errlog's own message pump
473/// (`errlog.c:671-681`), so it can only reach text that was handed to
474/// `errlogPrintf`. A `fprintf(stderr, ERL_ERROR ": …")` never enters that pump,
475/// so its escapes reach the stream whatever the stream is — a pipe, a file, a
476/// terminal alike. Every `.db`/`.dbd` loader diagnostic is such an `fprintf`.
477/// C composes this from the halves (`ANSI_RED("ERROR")`, `errlog.h:290`) and
478/// so would we, but `concat!` takes literals and not constants and a
479/// const-concat dependency is a poor trade for three tokens. The identity
480/// `ERL_ERROR == ANSI_ESC_RED ++ "ERROR" ++ ANSI_ESC_RESET` is asserted in
481/// `the_severity_words_carry_c_s_escapes` instead, so the halves and the whole
482/// cannot drift apart unnoticed.
483pub const ERL_ERROR: &str = "\x1b[31;1mERROR\x1b[0m";
484
485/// C `ERL_WARNING` (`errlog.h:299`) — `ANSI_MAGENTA("WARNING")`, unconditional
486/// for the same reason as [`ERL_ERROR`].
487///
488/// Use [`erl_warning`] instead for a word that goes out through `errlogPrintf`;
489/// those DO pass the strip and so must follow the console.
490///
491/// Written out rather than built from an `ANSI_ESC_MAGENTA` constant because
492/// this is magenta's only appearance in the workspace; the constant would have
493/// exactly one reader, and a whole word already spelled correctly is not the
494/// shape the escapes above earn — those exist because a second crate was
495/// otherwise defining `errlog.h`'s bytes for itself.
496pub const ERL_WARNING: &str = "\x1b[35;1mWARNING\x1b[0m";
497
498/// The word an *errlog* warning line carries — magenta on a terminal console
499/// and plain everywhere else.
500///
501/// C spells it `ANSI_MAGENTA("WARNING")` ([`ERL_WARNING`]) at the call site
502/// either way; the difference is that errlog strips the escapes at print time
503/// when its console is not a terminal (`errlog.c:672-681`,
504/// `pvt.ttyConsole = isATTY(stderr)` at `errlog.c:555`). `isATTY`
505/// (`errlog.c:218-237`) also demands a non-empty `$TERM`, on the grounds that a
506/// terminal that will not name itself cannot be assumed to understand escapes.
507/// Both halves of that rule are here, so an `epicsEnvSet`-style capture of a
508/// Rust IOC's stderr gets the same bytes as C's.
509///
510/// Verified head-to-head with the compiled `softIoc` (bind-conflict warning):
511/// redirected to a file it writes `cas WARNING: …`; under `script(1)` it writes
512/// `cas \x1b[35;1mWARNING\x1b[0m: …`.
513pub fn erl_warning() -> &'static str {
514 if errlog_console_paints() {
515 ERL_WARNING
516 } else {
517 "WARNING"
518 }
519}
520
521/// Whether an errlog line keeps the ANSI escapes its C literal carries.
522///
523/// C strips them at print time when the console is not a terminal
524/// (`errlog.c:789-793`, `pvt.ttyConsole = isATTY(stderr)` at `:555`), and
525/// `isATTY` (`:218-237`) also demands a non-empty `$TERM` on the grounds
526/// that a terminal which will not name itself cannot be assumed to
527/// understand escapes.
528///
529/// [`erl_warning`] answers this for one word. A call site whose C literal
530/// paints more than one span — `iocBuild`'s `asInit` failure carries both
531/// `ERL_ERROR` and an `ANSI_MAGENTA` sentence (`iocInit.c:188-190`) — asks
532/// it directly, so the predicate stays owned here rather than being
533/// re-derived per site.
534pub fn errlog_console_paints() -> bool {
535 use std::io::IsTerminal;
536 let term_names_itself = std::env::var_os("TERM").is_some_and(|t| !t.is_empty());
537 std::io::stderr().is_terminal() && term_names_itself
538}
539
540/// Emit a pre-formatted error message at the given severity.
541///
542/// C parity: `errlogSevVprintf`/`errlogSevPrintf` (`errlog.c:366-388`)
543/// — the C code prefixes `"sevr=%s "` and routes to the message queue,
544/// unconditionally. Here the prefix is preserved and the message is
545/// routed through `tracing` at a level mapped from the severity.
546///
547/// Unconditionally is the whole point: `errlogSevVprintf` tests no
548/// threshold, so an `errlogInfo` line reaches a C console whatever
549/// `errlogSetSevToLog` was told. See [`errlog_set_sev_to_log`].
550pub fn errlog_sev_printf(severity: ErrlogSevEnum, message: &str) {
551 let line = format!("sevr={} {}", severity.as_str(), message);
552 let record = as_record(&line);
553 match severity {
554 ErrlogSevEnum::Info => {
555 tracing::info!(target: "epics_base_rs::errlog", "{record}")
556 }
557 ErrlogSevEnum::Minor => {
558 tracing::warn!(target: "epics_base_rs::errlog", "{record}")
559 }
560 ErrlogSevEnum::Major | ErrlogSevEnum::Fatal => {
561 tracing::error!(target: "epics_base_rs::errlog", "{record}")
562 }
563 }
564 errlog_post(&line, ConsoleEcho::Yes);
565}
566
567/// Emit a pre-formatted message through the errlog facility
568/// unconditionally — C `errlogVprintf`/`errlogPrintf`
569/// (`errlog.c:315-335`), the *no-severity* variant.
570///
571/// Unlike [`errlog_sev_printf`] this carries no `sevr=` prefix (C
572/// `errlogVprintf` enqueues the caller's bytes verbatim). Neither call
573/// is gated: see [`errlog_set_sev_to_log`]. Routed through `tracing` at info
574/// level on the same `epics_base_rs::errlog` target, so an application's
575/// subscriber sees it on the errlog sink. Used by `stdio` device support
576/// for the `"errlog"` output stream (`devStdio.c` `logPrintf`).
577pub fn errlog_printf(message: &str) {
578 tracing::info!(target: "epics_base_rs::errlog", "{}", as_record(message));
579 errlog_post(message, ConsoleEcho::Yes);
580}
581
582/// C `errlogPrintfNoConsole` (`errlog.c:343-364`): the same message queue,
583/// but the console echo is suppressed for this line whatever `eltc` says.
584/// Listeners — the IOC log client among them — still receive it.
585pub fn errlog_printf_no_console(message: &str) {
586 tracing::info!(target: "epics_base_rs::errlog", "{}", as_record(message));
587 errlog_post(message, ConsoleEcho::No);
588}
589
590/// C `errlogMessage` (`errlog.c:337-341`) — `errlogPrintf("%s", message)`.
591pub fn errlog_message(message: &str) {
592 errlog_printf(message);
593}
594
595/// Debug-level runtime log line. Routes through the `tracing` facade.
596#[macro_export]
597macro_rules! rt_debug {
598 ($($arg:tt)*) => {
599 ::tracing::debug!(target: "epics_base_rs::runtime", "{}", format!($($arg)*));
600 };
601}
602
603/// Info-level runtime log line. Routes through the `tracing` facade.
604#[macro_export]
605macro_rules! rt_info {
606 ($($arg:tt)*) => {
607 ::tracing::info!(target: "epics_base_rs::runtime", "{}", format!($($arg)*));
608 };
609}
610
611/// Warn-level runtime log line. Routes through the `tracing` facade.
612#[macro_export]
613macro_rules! rt_warn {
614 ($($arg:tt)*) => {
615 ::tracing::warn!(target: "epics_base_rs::runtime", "{}", format!($($arg)*));
616 };
617}
618
619/// Error-level runtime log line. Routes through the `tracing` facade.
620#[macro_export]
621macro_rules! rt_error {
622 ($($arg:tt)*) => {
623 ::tracing::error!(target: "epics_base_rs::runtime", "{}", format!($($arg)*));
624 };
625}
626
627// ---------------------------------------------------------------------------
628// The errlog message queue — C `errlog.c` @`R7.0.10`.
629// ---------------------------------------------------------------------------
630
631/// C `errlog.c:44`. `errlogInit` raises anything smaller to this.
632pub const MIN_BUFFER_SIZE: usize = 1280;
633/// C `errlog.c:45` — also the `maxMsgSize` `errlogInit` uses.
634pub const MIN_MESSAGE_SIZE: usize = 256;
635/// C `errlog.c:46`.
636pub const MAX_MESSAGE_SIZE: usize = 0x00ff_ffff;
637
638/// What `errlogAddListener` hands back.
639///
640/// C keys a listener on the `(function pointer, void *pPrivate)` pair, because
641/// that is the only identity a C callback has, and `errlogRemoveListeners`
642/// removes *every* node matching it. A Rust closure has no comparable identity
643/// — two clones of one closure are indistinguishable and `fn` pointers to
644/// generic shims collide — so the registration hands back a token instead.
645/// Every registration is therefore removable exactly once and never removes a
646/// stranger's, which is a property C's pair-matching does not have.
647#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
648pub struct ErrlogListenerId(u64);
649
650/// A registered listener, shared with the worker's per-drain snapshot.
651type ErrlogListenerFn = std::sync::Arc<dyn Fn(&str) + Send + Sync>;
652
653/// One buffered message. C stores a flag byte and a NUL-terminated string in a
654/// flat arena; the byte layout is not observable, but which messages the arena
655/// ACCEPTS is, so `pos` below tracks C's arithmetic exactly.
656struct ErrlogBuf {
657 entries: Vec<String>,
658 /// C `buffer_t::pos` — bytes consumed, `1 + nchar + 1` per message
659 /// (`errlog.c:158`).
660 pos: usize,
661}
662
663impl ErrlogBuf {
664 fn new() -> Self {
665 Self {
666 entries: Vec::new(),
667 pos: 0,
668 }
669 }
670}
671
672/// Everything C guards with `pvt.msgQueueLock`.
673struct ErrlogQueue {
674 buf_size: usize,
675 max_msg_size: usize,
676 log: ErrlogBuf,
677 print: ErrlogBuf,
678 n_lost: usize,
679 at_exit: bool,
680 to_console: bool,
681 flush_seq: u64,
682}
683
684struct Errlog {
685 queue: std::sync::Mutex<ErrlogQueue>,
686 /// C `pvt.waitForWork`.
687 work: std::sync::Condvar,
688 /// C `pvt.waitForSeq`.
689 seq: std::sync::Condvar,
690 listeners: std::sync::Mutex<Vec<(ErrlogListenerId, ErrlogListenerFn)>>,
691 next_id: std::sync::atomic::AtomicU64,
692 /// Set once, after the worker thread is known to exist.
693 ///
694 /// C has no equivalent because C has no such state: `errlogInit2` calls
695 /// `cantProceed` when the thread will not start (`errlog.c:604-606`), so
696 /// every later line runs in a process that has a drainer. This port keeps
697 /// the IOC alive instead, which makes "no drainer" reachable — and a
698 /// producer that waits for a drain that can never happen would hang the
699 /// IOC on its first log line, turning a degraded log sink into a dead IOC.
700 worker_running: std::sync::atomic::AtomicBool,
701}
702
703static ERRLOG: std::sync::OnceLock<&'static Errlog> = std::sync::OnceLock::new();
704
705/// C `errlogInit(bufsize)` — `errlogInit2(bufsize, MIN_MESSAGE_SIZE)`
706/// (`errlog.c:609-612`). Idempotent: C runs `errlogInitPvt` under
707/// `epicsThreadOnce`, so the FIRST call fixes the sizes and every later one is
708/// a no-op whatever it asks for.
709pub fn errlog_init(bufsize: usize) {
710 errlog_init2(bufsize, MIN_MESSAGE_SIZE);
711}
712
713/// C `errlogInit2` (`errlog.c:583-607`): both sizes are clamped before the
714/// once-init, and the worker thread is started there.
715pub fn errlog_init2(bufsize: usize, max_msg_size: usize) {
716 errlog_pvt2(bufsize, max_msg_size);
717}
718
719/// [`errlog_init`]'s private half — the once-initialised state itself. Every
720/// entry point below starts here, exactly as every C entry point starts with
721/// `errlogInit(0)`.
722fn errlog_pvt() -> &'static Errlog {
723 errlog_pvt2(0, MIN_MESSAGE_SIZE)
724}
725
726fn errlog_pvt2(bufsize: usize, max_msg_size: usize) -> &'static Errlog {
727 ERRLOG.get_or_init(|| {
728 let (buf_size, max_msg_size) = errlog_clamp_sizes(bufsize, max_msg_size);
729 let errlog: &'static Errlog = Box::leak(Box::new(Errlog {
730 queue: std::sync::Mutex::new(ErrlogQueue {
731 buf_size,
732 max_msg_size,
733 log: ErrlogBuf::new(),
734 print: ErrlogBuf::new(),
735 n_lost: 0,
736 at_exit: false,
737 // C `errlogInitPvt`: `pvt.toConsole = TRUE`.
738 to_console: true,
739 flush_seq: 0,
740 }),
741 work: std::sync::Condvar::new(),
742 seq: std::sync::Condvar::new(),
743 listeners: std::sync::Mutex::new(Vec::new()),
744 next_id: std::sync::atomic::AtomicU64::new(1),
745 worker_running: std::sync::atomic::AtomicBool::new(false),
746 }));
747 // C `epicsThreadCreateOpt("errlog", …)` at `epicsThreadPriorityLow`
748 // with `epicsThreadStackSmall` (`errlog.c:568-574`).
749 let spawned = crate::runtime::task::spawn_dedicated_thread(
750 "errlog".to_string(),
751 crate::runtime::task::ThreadPriority::Low,
752 crate::runtime::task::StackSizeClass::Small,
753 move || errlog_worker(errlog),
754 );
755 if spawned.is_ok() {
756 errlog
757 .worker_running
758 .store(true, std::sync::atomic::Ordering::Relaxed);
759 } else {
760 // C exits the process when the thread cannot be created
761 // (`errlog.c:604-606`). Here the queue still accepts and still
762 // accounts; only delivery stops, so say so rather than kill an
763 // IOC over a log sink.
764 eprintln!("errlogInit failed: no errlog thread, listeners will not be called");
765 }
766 errlog
767 })
768}
769
770/// C `errlogThread` (`errlog.c:624-720`): swap the buffers, drain the snapshot
771/// with the queue UNLOCKED, then report anything the arena refused.
772fn errlog_worker(errlog: &'static Errlog) {
773 let mut q = errlog.queue.lock().expect("errlog queue");
774 loop {
775 q.flush_seq += 1;
776 errlog.seq.notify_all();
777
778 if q.log.entries.is_empty() {
779 if q.at_exit {
780 break;
781 }
782 q = errlog.work.wait(q).expect("errlog queue");
783 continue;
784 }
785
786 let n_lost = std::mem::take(&mut q.n_lost);
787 let to_console = q.to_console;
788 // C swaps `pvt.log` and `pvt.print` so logging can continue while
789 // this pass drains (`errlog.c:650-655`); the spare buffer goes back
790 // as `q.print` at the bottom of the loop.
791 let spare = std::mem::replace(&mut q.print, ErrlogBuf::new());
792 let mut print = std::mem::replace(&mut q.log, spare);
793 drop(q);
794
795 // A snapshot, not the live list: a listener that removes itself from
796 // inside its own callback is C's `active`/`removed` dance
797 // (`errlog.c:684-697`), and taking a copy makes that state
798 // unrepresentable — `errlog_remove_listener` can never deadlock
799 // against a running callback, and the removal simply takes effect on
800 // the next drain.
801 let listeners: Vec<_> = errlog
802 .listeners
803 .lock()
804 .expect("errlog listeners")
805 .iter()
806 .map(|(_, f)| std::sync::Arc::clone(f))
807 .collect();
808
809 for text in print.entries.drain(..) {
810 // C strips before the listeners see it, always (`errlog.c:678-681`).
811 let stripped = errlog_strip_ansi(&text);
812 for listener in &listeners {
813 listener(&stripped);
814 }
815 }
816 print.pos = 0;
817
818 if n_lost > 0 && to_console {
819 eprintln!("errlog: lost {n_lost} messages");
820 }
821
822 q = errlog.queue.lock().expect("errlog queue");
823 q.print = print;
824 }
825}
826
827/// Whether this call site puts the message on the console at all — C's
828/// `localEcho` argument to `msgbufCommit`, which is `pvt.toConsole` for
829/// `errlogVprintf`/`errlogSevVprintf` and a hard `0` for the `NoConsole` pair
830/// (`errlog.c:334`, `:365`, `:388`).
831#[derive(Clone, Copy, PartialEq, Eq)]
832enum ConsoleEcho {
833 Yes,
834 No,
835}
836
837/// C `msgbufAlloc`/`msgbufCommit` (`errlog.c:113-188`) as one step: admit the
838/// message, wake the worker, echo it, and — the part that makes the arena a
839/// bound on *latency* rather than on messages — wait for the drain.
840///
841/// The admission rule is C's: a message is accepted only when the WORST CASE
842/// still fits — `bufSize - pos >= 1 + maxMsgSize` — so a burst is dropped and
843/// counted instead of consuming memory, and the count reaches the console as
844/// `errlog: lost N messages`.
845///
846/// # Why the flush is not optional
847///
848/// C pairs that refusal with back-pressure in the same function: after a
849/// message that echoes to the console, logged from a thread that may block and
850/// outside shutdown, `msgbufCommit` runs `errlogFlush()` (`errlog.c:186-187`).
851/// The producer therefore cannot get ahead of the drainer by more than one
852/// message, and `pos` is back at 0 before the next call — which is why a C IOC
853/// boots a 400-line script through a 1280-byte arena and loses nothing.
854///
855/// Porting the arena without the flush turned a bound on memory into silent
856/// loss of listener copies: a boot burst filled `pos` and every further line
857/// was refused until the worker happened to run. The fix belongs here and not
858/// in [`MIN_BUFFER_SIZE`], because no buffer size is large enough — the rule C
859/// relies on is that the producer waits, not that the arena is big.
860///
861/// The three gates are C's and each closes a real path: `ok_to_block` keeps a
862/// scan or serving thread off the drain and, because
863/// [`enter_ioc_thread`](crate::runtime::task::enter_ioc_thread) clears it, also
864/// stops the errlog worker from flushing into itself when a listener logs;
865/// `at_exit` matches C's `!atExit`, the worker having stopped; `local_echo`
866/// matches C's argument, so `errlogPrintfNoConsole` and `eltc(0)` cost no wait.
867fn errlog_post(message: &str, echo: ConsoleEcho) {
868 let errlog = errlog_pvt();
869 // C reads it before the lock (`errlog.c:147`); it is this thread's own.
870 let ok_to_block = crate::runtime::task::thread_is_ok_to_block();
871
872 let mut q = errlog.queue.lock().expect("errlog queue");
873 let was_empty = q.log.pos == 0;
874 let at_exit = q.at_exit;
875 // One read of `eltc` per message, under the same lock that admits it.
876 let local_echo = echo == ConsoleEcho::Yes && q.to_console;
877 let accepted = q.accept(message);
878 drop(q);
879
880 if accepted && was_empty {
881 errlog.work.notify_all();
882 }
883 console_fallback(message, local_echo);
884
885 // C `msgbufCommit`'s tail (`errlog.c:186-187`). `accepted` stands for C's
886 // `msgbufAlloc` having returned a buffer at all: a refused message never
887 // reaches `msgbufCommit` and so never flushes there either.
888 if accepted
889 && local_echo
890 && ok_to_block
891 && !at_exit
892 && errlog
893 .worker_running
894 .load(std::sync::atomic::Ordering::Relaxed)
895 {
896 errlog_flush();
897 }
898}
899
900/// C `msgbufCommit`'s truncation marker (`errlog.c:151`).
901const TRUNCATED: &str = "<<TRUNCATED>>\n";
902
903impl ErrlogQueue {
904 /// The whole admission decision, in one place so it can be tested at its
905 /// boundaries without a worker thread. `true` when the message was taken.
906 fn accept(&mut self, message: &str) -> bool {
907 if self.at_exit {
908 return false;
909 }
910 // C `msgbufAlloc` (`errlog.c:124-128`): a message is taken only when
911 // the WORST CASE still fits, so the arena bounds memory instead of
912 // growing, and the refusals are counted for the console.
913 if self.buf_size - self.log.pos < 1 + self.max_msg_size {
914 self.n_lost += 1;
915 return false;
916 }
917
918 // C `msgbufCommit` (`errlog.c:145-155`): `nchar` is what `snprintf`
919 // WOULD have written, so a message at or past `maxMsgSize` is cut to
920 // `maxMsgSize - 1` bytes with its tail overwritten by the marker.
921 let max = self.max_msg_size;
922 let text = if message.len() >= max {
923 let mut cut = (max - 1).saturating_sub(TRUNCATED.len());
924 while cut > 0 && !message.is_char_boundary(cut) {
925 cut -= 1;
926 }
927 let mut t = String::with_capacity(max);
928 t.push_str(&message[..cut]);
929 t.push_str(TRUNCATED);
930 t
931 } else {
932 message.to_string()
933 };
934
935 self.log.pos += 1 + text.len() + 1;
936 self.log.entries.push(text);
937 true
938 }
939}
940
941/// C `errlogInit2`'s two clamps (`errlog.c:591-599`), extracted so the
942/// boundaries are testable after the once-init has already run.
943fn errlog_clamp_sizes(bufsize: usize, max_msg_size: usize) -> (usize, usize) {
944 (
945 bufsize.max(MIN_BUFFER_SIZE),
946 max_msg_size.clamp(MIN_MESSAGE_SIZE, MAX_MESSAGE_SIZE),
947 )
948}
949
950/// C `errlogSequence` (`errlog.c:189-217`): block until the worker completes
951/// one pass of its loop.
952fn errlog_sequence() {
953 let errlog = errlog_pvt();
954 let mut q = errlog.queue.lock().expect("errlog queue");
955 if q.at_exit {
956 return;
957 }
958 let seq = q.flush_seq;
959 while q.flush_seq == seq && !q.at_exit {
960 errlog.work.notify_all();
961 q = errlog.seq.wait(q).expect("errlog queue");
962 }
963}
964
965/// C `errlogFlush` (`errlog.c:614-622`): TWO sequences, because it takes both
966/// buffers being handled to know every message logged so far has been seen.
967pub fn errlog_flush() {
968 errlog_sequence();
969 errlog_sequence();
970}
971
972/// C `errlogAddListener` (`errlog.c:417-431`).
973///
974/// The listener is called on the errlog worker thread with the message text
975/// after ANSI stripping — never on the thread that logged it.
976pub fn errlog_add_listener<F>(listener: F) -> ErrlogListenerId
977where
978 F: Fn(&str) + Send + Sync + 'static,
979{
980 let errlog = errlog_pvt();
981 let id = ErrlogListenerId(
982 errlog
983 .next_id
984 .fetch_add(1, std::sync::atomic::Ordering::Relaxed),
985 );
986 errlog
987 .listeners
988 .lock()
989 .expect("errlog listeners")
990 .push((id, std::sync::Arc::new(listener)));
991 id
992}
993
994/// Remove the listener registered under `id`; `true` when one was there.
995///
996/// C's `errlogRemoveListeners(listener, pPrivate)` returns how many nodes
997/// matched the pair (`errlog.c:434-462`); a token matches at most one, so the
998/// count degenerates to a boolean. Safe to call from inside a listener: the
999/// worker drains against a snapshot, so this takes the lock immediately and
1000/// the removal applies from the next message on.
1001pub fn errlog_remove_listener(id: ErrlogListenerId) -> bool {
1002 let errlog = errlog_pvt();
1003 let mut listeners = errlog.listeners.lock().expect("errlog listeners");
1004 let before = listeners.len();
1005 listeners.retain(|(other, _)| *other != id);
1006 listeners.len() != before
1007}
1008
1009/// How many listeners are registered — C has no such call; this exists for
1010/// the tests and for `iocLogShow`.
1011#[must_use]
1012pub fn errlog_listener_count() -> usize {
1013 errlog_pvt()
1014 .listeners
1015 .lock()
1016 .expect("errlog listeners")
1017 .len()
1018}
1019
1020/// C `errlogShow(level)` (`errlog.c:694-720`). Returns the lines rather than
1021/// printing them, so the iocsh command can send them through its own
1022/// redirected output — the same split `ioc_log_show` uses.
1023///
1024/// Level 0 is the two sizes, level 1 adds the listener count, level 2 adds
1025/// both buffers. The first three quantities are C's own, read from the port's
1026/// `buf_size`, `max_msg_size` and listener list.
1027///
1028/// The buffer dump is NOT C's bytes, and stating what it is beats inventing a
1029/// field to match C's line shape. C packs the queued messages into a flat
1030/// arena and prints `pvt.log->base`, which `printf("%s")` cuts at the first
1031/// NUL — so C shows the FIRST queued message — then `printf("%*s^\n", pos,
1032/// "")`, a caret standing at the byte offset the next message would go to.
1033/// The port's queue is a `Vec<String>` carrying C's `pos` arithmetic
1034/// alongside it (`pos` is what decides acceptance, so it is kept exactly),
1035/// and there is no arena for a caret to point into. So every queued message
1036/// is printed, one per line, and the position is stated as a number instead
1037/// of drawn.
1038#[must_use]
1039pub fn errlog_show(level: u32) -> Vec<String> {
1040 let errlog = errlog_pvt();
1041 // Snapshot under the queue lock, format outside it: the queue is the one
1042 // lock a formatting path must not be holding if it ever logs.
1043 let (buf_size, max_msg_size, log, print) = {
1044 let q = errlog.queue.lock().expect("errlog queue");
1045 (
1046 q.buf_size,
1047 q.max_msg_size,
1048 (q.log.entries.clone(), q.log.pos),
1049 (q.print.entries.clone(), q.print.pos),
1050 )
1051 };
1052
1053 let mut out = vec![
1054 "Error log:".to_string(),
1055 format!(" buffer size: {buf_size}"),
1056 format!(" max message size: {max_msg_size}"),
1057 ];
1058 if level > 0 {
1059 // Taken after the queue lock is released, never under it.
1060 out.push(format!(
1061 " number of listeners: {}",
1062 errlog_listener_count()
1063 ));
1064 }
1065 if level > 1 {
1066 for (which, (entries, pos)) in [("log", log), ("print", print)] {
1067 out.push(format!(" buffer({which}) contents:"));
1068 out.extend(
1069 entries
1070 .iter()
1071 .map(|msg| format!(" {}", msg.trim_end_matches('\n'))),
1072 );
1073 out.push(format!(
1074 " buffer({which}) position: {pos} of {buf_size} bytes"
1075 ));
1076 }
1077 }
1078 out
1079}
1080
1081/// C `eltc(yesno)` (`errlog.c:465-473`) — "error log to console". Returns the
1082/// previous setting.
1083///
1084/// C's console lives in the errlog worker, so `eltc` gates the worker's
1085/// `fprintf`. The port's console is the `tracing` facade, emitted on the
1086/// thread that logged the message so a scoped subscriber and the caller's span
1087/// still see it; `eltc` therefore gates that call site instead. The setting
1088/// and its observable — a quiet console — are the same either way.
1089pub fn eltc(yesno: bool) -> bool {
1090 let errlog = errlog_pvt();
1091 let previous = {
1092 let mut q = errlog.queue.lock().expect("errlog queue");
1093 std::mem::replace(&mut q.to_console, yesno)
1094 };
1095 errlog_flush();
1096 previous
1097}
1098
1099/// Whether errlog messages currently reach the console — the `eltc` setting.
1100#[must_use]
1101pub fn errlog_to_console() -> bool {
1102 errlog_pvt().queue.lock().expect("errlog queue").to_console
1103}
1104
1105/// How many messages the buffer has refused since the last drain reported.
1106#[must_use]
1107pub fn errlog_messages_lost() -> usize {
1108 errlog_pvt().queue.lock().expect("errlog queue").n_lost
1109}
1110
1111/// C `errlogStripANSI` (`errlog.c:269-313`) — remove CSI escape sequences.
1112///
1113/// Transcribed rather than written afresh, edges included: a lone `ESC` not
1114/// followed by `[` loses only the `ESC`, and a CSI run ends at the first byte
1115/// outside `?;0-9` — consuming one final letter if there is one, and nothing
1116/// if the sequence is truncated. Only ASCII bytes are ever dropped, so UTF-8
1117/// text survives.
1118#[must_use]
1119pub fn errlog_strip_ansi(message: &str) -> String {
1120 let b = message.as_bytes();
1121 let mut out = Vec::with_capacity(b.len());
1122 let mut i = 0;
1123 while i < b.len() {
1124 if b[i] != 0x1b {
1125 out.push(b[i]);
1126 i += 1;
1127 continue;
1128 }
1129 i += 1; // the ESC itself is dropped
1130 if i < b.len() && b[i] == b'[' {
1131 i += 1;
1132 while i < b.len() && (b[i] == b'?' || b[i] == b';' || b[i].is_ascii_digit()) {
1133 i += 1;
1134 }
1135 if i < b.len() && b[i].is_ascii_alphabetic() {
1136 i += 1;
1137 }
1138 }
1139 }
1140 String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145 use super::*;
1146 use serial_test::serial;
1147
1148 #[test]
1149 fn test_log_macros_compile() {
1150 rt_debug!("debug message {}", 42);
1151 rt_info!("info message");
1152 rt_warn!("warn: {}", "something");
1153 rt_error!("error: {} {}", "bad", "thing");
1154 }
1155
1156 /// The condition the console fallback keys on. With no subscriber the
1157 /// `tracing` dispatcher reports `OFF`, and every errlog line in the
1158 /// process is being discarded — the state each RTEMS IOC binary runs in,
1159 /// because installing a subscriber is the application's job and those
1160 /// entry points do not do it (`tracing-subscriber` sits behind an
1161 /// optional feature that also pulls a Prometheus exporter).
1162 #[test]
1163 #[serial]
1164 fn with_no_subscriber_nothing_is_listening() {
1165 assert!(
1166 nothing_is_listening(),
1167 "the test process has no global subscriber, so errlog output is \
1168 being discarded and the console fallback must engage"
1169 );
1170 }
1171
1172 /// …and with one installed the fallback must stand down, or every hosted
1173 /// IOC gets each errlog line twice: once through its own sink and once on
1174 /// stderr.
1175 #[test]
1176 #[serial]
1177 fn with_a_subscriber_the_fallback_stands_down() {
1178 use tracing::subscriber::with_default;
1179 let captured = with_default(tracing_subscriber::registry(), nothing_is_listening);
1180 assert!(
1181 !captured,
1182 "a scoped subscriber is listening, so the console fallback must not fire"
1183 );
1184 }
1185
1186 /// The console subscriber must be one of the subscribers that stands the
1187 /// fallback down. It is not automatic: a `Subscriber` whose
1188 /// `max_level_hint` is left at the default reports no hint, and this file's
1189 /// own fallback would then print every errlog line a second time on the
1190 /// very target the subscriber exists for.
1191 #[test]
1192 #[serial]
1193 fn the_console_subscriber_declares_itself_to_the_dispatcher() {
1194 use tracing::level_filters::LevelFilter;
1195 use tracing::subscriber::with_default;
1196
1197 let (still_mute, level) = with_default(ConsoleSubscriber, || {
1198 (nothing_is_listening(), LevelFilter::current())
1199 });
1200 assert!(
1201 !still_mute,
1202 "the console subscriber is listening, so the errlog fallback must \
1203 not also print — that is every line twice"
1204 );
1205 assert_eq!(
1206 level,
1207 LevelFilter::INFO,
1208 "the console takes INFO and above, matching a C IOC's errlog console"
1209 );
1210 }
1211
1212 /// A capturing stand-in that shares the console's rendering. What it
1213 /// asserts is [`render_event`], which is the whole of what the console
1214 /// subscriber does with an event.
1215 struct CapturingSubscriber(std::sync::Arc<std::sync::Mutex<Vec<String>>>);
1216
1217 impl tracing::Subscriber for CapturingSubscriber {
1218 fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
1219 *metadata.level() <= tracing::Level::INFO
1220 }
1221 fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
1222 Some(tracing::level_filters::LevelFilter::INFO)
1223 }
1224 fn event(&self, event: &tracing::Event<'_>) {
1225 self.0.lock().expect("sink").push(render_event(event));
1226 }
1227 fn new_span(&self, _s: &tracing::span::Attributes<'_>) -> tracing::span::Id {
1228 tracing::span::Id::from_u64(1)
1229 }
1230 fn record(&self, _s: &tracing::span::Id, _v: &tracing::span::Record<'_>) {}
1231 fn record_follows_from(&self, _s: &tracing::span::Id, _f: &tracing::span::Id) {}
1232 fn enter(&self, _s: &tracing::span::Id) {}
1233 fn exit(&self, _s: &tracing::span::Id) {}
1234 }
1235
1236 /// The rendered line carries the message *unquoted* and the structured
1237 /// fields as `key=value`. The message field arrives as `format_args!`, so
1238 /// rendering it through `Debug` is what keeps it readable; switching to a
1239 /// `record_str` arm would wrap every diagnostic in quotes.
1240 #[test]
1241 #[serial]
1242 fn the_console_line_carries_message_and_fields() {
1243 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1244 tracing::subscriber::with_default(CapturingSubscriber(seen.clone()), || {
1245 tracing::warn!(target: "epics_base_rs::test", nth = 7, "refused a client");
1246 tracing::debug!(target: "epics_base_rs::test", "not at console level");
1247 });
1248
1249 let lines = seen.lock().expect("sink").clone();
1250 assert_eq!(
1251 lines,
1252 vec!["WARN epics_base_rs::test: refused a client nth=7".to_string()],
1253 "one INFO-or-above event, message unquoted, fields appended"
1254 );
1255 }
1256
1257 /// Below-INFO events must not reach the console — asserted above by the
1258 /// `debug!` that produced no line, and here at the filter itself so the
1259 /// reason is not mistaken for a rendering accident.
1260 #[test]
1261 #[serial]
1262 fn the_console_subscriber_declines_below_info() {
1263 use tracing::level_filters::LevelFilter;
1264 let taken = tracing::subscriber::with_default(ConsoleSubscriber, || {
1265 LevelFilter::current() >= LevelFilter::DEBUG
1266 });
1267 assert!(!taken, "DEBUG must be below the console's level");
1268 }
1269
1270 #[test]
1271 fn sev_enum_strings_match_c() {
1272 // C `errlogSevEnumString` (errlog.h:60-65).
1273 assert_eq!(errlog_sev_enum_string(ErrlogSevEnum::Info), "info");
1274 assert_eq!(errlog_sev_enum_string(ErrlogSevEnum::Minor), "minor");
1275 assert_eq!(errlog_sev_enum_string(ErrlogSevEnum::Major), "major");
1276 assert_eq!(errlog_sev_enum_string(ErrlogSevEnum::Fatal), "fatal");
1277 }
1278
1279 #[test]
1280 fn sev_enum_ordering() {
1281 assert!(ErrlogSevEnum::Info < ErrlogSevEnum::Minor);
1282 assert!(ErrlogSevEnum::Minor < ErrlogSevEnum::Major);
1283 assert!(ErrlogSevEnum::Major < ErrlogSevEnum::Fatal);
1284 }
1285
1286 #[test]
1287 #[serial(errlog_sev)]
1288 fn sev_to_log_threshold_roundtrips() {
1289 errlog_set_sev_to_log(ErrlogSevEnum::Major);
1290 assert_eq!(errlog_get_sev_to_log(), ErrlogSevEnum::Major);
1291 // Restore the C default.
1292 errlog_set_sev_to_log(ErrlogSevEnum::Info);
1293 assert_eq!(errlog_get_sev_to_log(), ErrlogSevEnum::Info);
1294 }
1295
1296 /// C's `pvt` is a file-scope static, so `pvt.sevToLog` starts at 0 —
1297 /// `errlogInfo`. Every test in the `errlog_sev` group restores that
1298 /// value, so this holds whichever order they run in.
1299 #[test]
1300 #[serial(errlog_sev)]
1301 fn sev_to_log_defaults_to_info_like_c_zero_init() {
1302 assert_eq!(
1303 errlog_get_sev_to_log(),
1304 ErrlogSevEnum::Info,
1305 "C zero-initialises pvt.sevToLog to errlogInfo, not errlogMinor"
1306 );
1307 }
1308
1309 /// The setting is inert. `errlogSevVprintf` (`errlog.c:376-388`) tests
1310 /// no threshold, and no other C function reads `pvt.sevToLog`, so a
1311 /// C IOC prints `sevr=info` lines even after `errlogSetSevToLog(major)`
1312 /// — e.g. `devBiDbState`'s "Creating new db state" notice at iocInit.
1313 #[test]
1314 #[serial(errlog_sev)]
1315 fn sev_printf_emits_every_severity_whatever_sev_to_log_says() {
1316 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1317 errlog_set_sev_to_log(ErrlogSevEnum::Fatal);
1318 tracing::subscriber::with_default(CapturingSubscriber(seen.clone()), || {
1319 errlog_sev_printf(ErrlogSevEnum::Info, "creating new db state 'mystate'");
1320 errlog_sev_printf(ErrlogSevEnum::Minor, "a minor complaint");
1321 errlog_sev_printf(ErrlogSevEnum::Major, "a major complaint");
1322 });
1323 errlog_set_sev_to_log(ErrlogSevEnum::Info);
1324
1325 let lines = seen.lock().expect("sink").clone();
1326 assert!(
1327 lines
1328 .iter()
1329 .any(|l| l.contains("sevr=info creating new db state 'mystate'")),
1330 "sevToLog=fatal must not suppress an errlogInfo line: {lines:?}"
1331 );
1332 assert!(
1333 lines
1334 .iter()
1335 .any(|l| l.contains("sevr=minor a minor complaint")),
1336 "sevToLog=fatal must not suppress an errlogMinor line: {lines:?}"
1337 );
1338 assert!(
1339 lines
1340 .iter()
1341 .any(|l| l.contains("sevr=major a major complaint")),
1342 "sevToLog=fatal must not suppress an errlogMajor line: {lines:?}"
1343 );
1344 }
1345 /// The distinction the whole hook exists for. A panic on a worker thread
1346 /// leaves an IOC that still listens and still answers searches, which is
1347 /// indistinguishable from health from outside; the announcement has to say
1348 /// so, because nothing else will.
1349 #[test]
1350 fn a_worker_panic_says_the_ioc_is_still_up_and_no_longer_whole() {
1351 let line = panic_announcement(
1352 Some("CAS-client-3"),
1353 "blocking.rs:412",
1354 "index out of bounds",
1355 );
1356 assert!(line.contains("CAS-client-3"), "{line}");
1357 assert!(line.contains("blocking.rs:412"), "{line}");
1358 assert!(line.contains("index out of bounds"), "{line}");
1359 assert!(
1360 line.contains("keeps listening"),
1361 "a worker panic must say the IOC survives it, or the console reads \
1362 like the IOC died when it did not: {line}"
1363 );
1364 assert!(
1365 !line.contains("going down"),
1366 "a worker panic must not claim the image is finished: {line}"
1367 );
1368 }
1369
1370 /// The other outcome, which is the opposite claim and must not be confused
1371 /// with it: the RTEMS build unwinds, so a panic that leaves `main` ends the
1372 /// image.
1373 #[test]
1374 fn an_entry_thread_panic_says_the_image_is_finished() {
1375 let line = panic_announcement(Some("main"), "realtime-ca-ioc.rs:118", "iocInit failed");
1376 assert!(
1377 line.contains("going down"),
1378 "a panic out of the entry thread ends the image, and the console is \
1379 the only place that can say so: {line}"
1380 );
1381 assert!(!line.contains("keeps listening"), "{line}");
1382 }
1383
1384 /// RTEMS threads that were not named through `thread::Builder` have no
1385 /// name, and the line must still identify itself rather than render an
1386 /// empty pair of backticks.
1387 #[test]
1388 fn an_unnamed_thread_is_still_named_something() {
1389 let line = panic_announcement(None, "x.rs:1", "boom");
1390 assert!(line.contains("<unnamed>"), "{line}");
1391 assert!(
1392 line.contains("keeps listening"),
1393 "an unnamed thread is not the entry thread — std names that one \
1394 `main` — so it takes the worker consequence: {line}"
1395 );
1396 }
1397
1398 /// Installing twice must not chain the hook onto itself: that prints every
1399 /// panic once per install, and the second copy looks like a second panic.
1400 ///
1401 /// Restores the default hook afterwards so a `cargo test` run — which,
1402 /// unlike `cargo nextest`, shares one process across tests — is not left
1403 /// with this one.
1404 #[test]
1405 #[serial]
1406 fn the_panic_hook_installs_once() {
1407 assert!(install_panic_hook(), "the first install takes effect");
1408 assert!(
1409 !install_panic_hook(),
1410 "a second install must be refused, not chained onto the first"
1411 );
1412 let _ = std::panic::take_hook();
1413 }
1414
1415 /// The hook replaces the previous one; it does not run it afterwards.
1416 ///
1417 /// Chaining would print the panic twice — this hook's line already carries
1418 /// the thread, site and payload — and would append `std`'s
1419 /// "run with `RUST_BACKTRACE=1`" note, which on the target is advice for an
1420 /// environment that does not exist. A sentinel hook proves the absence
1421 /// directly: if the previous hook still ran, it would flip the flag.
1422 #[test]
1423 #[serial]
1424 fn the_panic_hook_does_not_run_the_hook_it_replaced() {
1425 use std::sync::Arc;
1426 use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
1427
1428 let previous_ran = Arc::new(AtomicBool::new(false));
1429 let flag = previous_ran.clone();
1430 std::panic::set_hook(Box::new(move |_| {
1431 flag.store(true, AtomicOrdering::SeqCst);
1432 }));
1433
1434 assert!(install_panic_hook(), "the install takes effect");
1435 let caught = std::panic::catch_unwind(|| panic!("a panic the hook must report once"));
1436 assert!(caught.is_err(), "the panic was raised");
1437
1438 assert!(
1439 !previous_ran.load(AtomicOrdering::SeqCst),
1440 "the replaced hook must not run: chaining it doubles the console \
1441 output and appends a RUST_BACKTRACE note that cannot be acted on"
1442 );
1443 let _ = std::panic::take_hook();
1444 }
1445
1446 fn test_queue() -> ErrlogQueue {
1447 let (buf_size, max_msg_size) = errlog_clamp_sizes(0, 0);
1448 ErrlogQueue {
1449 buf_size,
1450 max_msg_size,
1451 log: ErrlogBuf::new(),
1452 print: ErrlogBuf::new(),
1453 n_lost: 0,
1454 at_exit: false,
1455 to_console: true,
1456 flush_seq: 0,
1457 }
1458 }
1459
1460 /// Boundary: both clamps. C raises a small `bufsize` to `MIN_BUFFER_SIZE`
1461 /// and holds `maxMsgSize` inside `[MIN_MESSAGE_SIZE, MAX_MESSAGE_SIZE]`
1462 /// (`errlog.c:591-599`), so `errlogInit(0)` — the call every entry point
1463 /// makes — produces 1280/256 and not 0/0.
1464 #[test]
1465 fn errlog_init_clamps_both_sizes_the_way_c_does() {
1466 assert_eq!(
1467 errlog_clamp_sizes(0, 0),
1468 (MIN_BUFFER_SIZE, MIN_MESSAGE_SIZE)
1469 );
1470 assert_eq!(
1471 errlog_clamp_sizes(MIN_BUFFER_SIZE - 1, MIN_MESSAGE_SIZE - 1),
1472 (MIN_BUFFER_SIZE, MIN_MESSAGE_SIZE)
1473 );
1474 assert_eq!(
1475 errlog_clamp_sizes(MIN_BUFFER_SIZE + 1, MIN_MESSAGE_SIZE + 1),
1476 (MIN_BUFFER_SIZE + 1, MIN_MESSAGE_SIZE + 1),
1477 "a size above the floor is kept"
1478 );
1479 assert_eq!(
1480 errlog_clamp_sizes(0, MAX_MESSAGE_SIZE + 1).1,
1481 MAX_MESSAGE_SIZE,
1482 "and the ceiling holds too"
1483 );
1484 }
1485
1486 /// Boundary: the admission rule. C takes a message only when the WORST
1487 /// case still fits — `bufSize - pos >= 1 + maxMsgSize` — so with the
1488 /// default 1280/256 the arena stops accepting once `pos` passes 1023,
1489 /// whatever the actual message lengths were. This is what makes the
1490 /// buffer a bound; a `Vec` that simply grew would never refuse.
1491 #[test]
1492 fn the_buffer_refuses_and_counts_once_the_worst_case_no_longer_fits() {
1493 let mut q = test_queue();
1494 let msg = "0123456789"; // 10 bytes, so 12 bytes of `pos` each
1495 let mut taken = 0;
1496 while q.accept(msg) {
1497 taken += 1;
1498 assert!(taken < 1000, "the arena must stop accepting");
1499 }
1500 // `pos` may reach 1023 and still admit, so the last accepted message
1501 // is the one that starts at 1020 — 86 of them, ending with `pos` past
1502 // the limit at 1032.
1503 assert_eq!(taken, 86, "the last message that starts at or below 1023");
1504 assert_eq!(q.log.pos, 86 * 12);
1505 assert_eq!(q.n_lost, 1, "the first refusal is counted");
1506 assert!(!q.accept(msg));
1507 assert_eq!(q.n_lost, 2, "and so is the next");
1508 assert_eq!(q.log.entries.len(), 86, "nothing lost was stored");
1509 }
1510
1511 /// Boundary: exactly at the limit. `pos == buf_size - (1 + max_msg_size)`
1512 /// still admits; one byte past does not.
1513 #[test]
1514 fn the_admission_boundary_is_inclusive() {
1515 let mut q = test_queue();
1516 q.log.pos = q.buf_size - (1 + q.max_msg_size);
1517 assert!(q.accept("x"), "the last worst case that fits is taken");
1518 let mut q = test_queue();
1519 q.log.pos = q.buf_size - q.max_msg_size;
1520 assert!(!q.accept("x"), "one byte past it is not");
1521 assert_eq!(q.n_lost, 1);
1522 }
1523
1524 /// The defect this arena had without C's back-pressure: a boot burst.
1525 ///
1526 /// 200 console-echoed messages is what a real `iocBoot` script produces,
1527 /// and 1280/256 admits 86 of them at a time. C loses none of it because
1528 /// `msgbufCommit` flushes after every echoed message (`errlog.c:186-187`),
1529 /// so `pos` is 0 again before the next call. The observable is both halves
1530 /// of "nothing was lost": the refusal counter, and the listener copies —
1531 /// the console copy is written by the producer here and would survive the
1532 /// loss, which is exactly why the counter alone would have looked fine.
1533 ///
1534 /// Deterministic, not a race: the last message's flush cannot return until
1535 /// the worker has completed a pass with it in hand.
1536 #[test]
1537 #[serial(errlog_listeners)]
1538 fn a_boot_sized_burst_of_console_messages_loses_none() {
1539 let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1540 let sink = std::sync::Arc::clone(&seen);
1541 let id = errlog_add_listener(move |_| {
1542 sink.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1543 });
1544
1545 const BURST: usize = 200;
1546 for i in 0..BURST {
1547 errlog_printf(&format!("burst line {i}\n"));
1548 }
1549
1550 let lost = errlog_messages_lost();
1551 let heard = seen.load(std::sync::atomic::Ordering::SeqCst);
1552 assert!(errlog_remove_listener(id));
1553 assert_eq!(lost, 0, "the arena refused {lost} of {BURST}");
1554 assert_eq!(heard, BURST, "every message reached the listeners");
1555 }
1556
1557 /// The invariant the previous test rests on, stated directly and in both
1558 /// directions: **a producer that echoes to the console and may block does
1559 /// not return until the worker has drained its message; one that may not
1560 /// block returns straight away.**
1561 ///
1562 /// C `msgbufCommit` (`errlog.c:186-187`). Holding the worker inside a
1563 /// listener makes both halves decidable rather than timed: while the gate
1564 /// is shut the drain cannot complete, so a producer that waits for it
1565 /// cannot return, and a producer that does not wait must already have.
1566 #[test]
1567 #[serial(errlog_listeners)]
1568 fn only_a_blocking_producer_waits_for_the_drain() {
1569 for prologue in [false, true] {
1570 let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
1571 let (entered_tx, entered_rx) = std::sync::mpsc::channel::<()>();
1572 let release_rx = std::sync::Mutex::new(release_rx);
1573 let holding = std::sync::atomic::AtomicBool::new(false);
1574 let id = errlog_add_listener(move |_| {
1575 if !holding.swap(true, std::sync::atomic::Ordering::SeqCst) {
1576 let _ = entered_tx.send(());
1577 let _ = release_rx.lock().expect("gate").recv();
1578 }
1579 });
1580
1581 // Shuts the gate, and is itself a producer of the kind under test
1582 // only in the `false` pass — so the gate is entered from a message
1583 // whose own wait has already been satisfied or never taken.
1584 let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
1585 let producer = std::thread::spawn(move || {
1586 if prologue {
1587 let _ = crate::runtime::task::enter_ioc_thread(
1588 crate::runtime::task::ThreadPriority::ScanLow,
1589 );
1590 }
1591 errlog_printf("gated\n");
1592 let _ = done_tx.send(());
1593 });
1594 entered_rx
1595 .recv_timeout(std::time::Duration::from_secs(10))
1596 .expect("the worker reaches the listener");
1597
1598 let returned_while_gated = done_rx
1599 .recv_timeout(std::time::Duration::from_millis(300))
1600 .is_ok();
1601 let _ = release_tx.send(());
1602 if !returned_while_gated {
1603 done_rx
1604 .recv_timeout(std::time::Duration::from_secs(10))
1605 .expect("the producer returns once the drain completes");
1606 }
1607 producer.join().expect("the producer thread");
1608 errlog_flush();
1609 assert!(errlog_remove_listener(id));
1610
1611 assert_eq!(
1612 returned_while_gated, prologue,
1613 "prologue={prologue}: C waits exactly when `epicsThreadIsOkToBlock`"
1614 );
1615 }
1616 }
1617
1618 /// The same burst with the console off, which is C's other answer and the
1619 /// proof that the burst really does overflow.
1620 ///
1621 /// `eltc(0)` makes `localEcho` 0, so `msgbufCommit` takes no back-pressure
1622 /// and the arena behaves as a pure bound: the messages past 86 are refused
1623 /// and counted. Holding the worker inside a listener makes that the only
1624 /// possible outcome rather than a race with the drain — and it is the same
1625 /// hold that shows the previous test is measuring something, since without
1626 /// the flush that burst is this one.
1627 #[test]
1628 #[serial(errlog_listeners)]
1629 fn the_same_burst_with_the_console_off_overflows_and_is_counted() {
1630 let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
1631 let (entered_tx, entered_rx) = std::sync::mpsc::channel::<()>();
1632 let release_rx = std::sync::Mutex::new(release_rx);
1633 let holding = std::sync::atomic::AtomicBool::new(false);
1634 let id = errlog_add_listener(move |_| {
1635 if !holding.swap(true, std::sync::atomic::Ordering::SeqCst) {
1636 let _ = entered_tx.send(());
1637 let _ = release_rx.lock().expect("gate").recv();
1638 }
1639 });
1640
1641 let was = eltc(false);
1642 errlog_printf("prime\n");
1643 entered_rx
1644 .recv_timeout(std::time::Duration::from_secs(10))
1645 .expect("the worker reaches the listener");
1646
1647 for i in 0..200 {
1648 errlog_printf(&format!("burst line {i}\n"));
1649 }
1650 let lost = errlog_messages_lost();
1651
1652 let _ = release_tx.send(());
1653 errlog_flush();
1654 eltc(was);
1655 assert!(errlog_remove_listener(id));
1656 assert!(
1657 lost > 0,
1658 "a stalled drain plus 200 messages must overflow a 1280-byte arena"
1659 );
1660 }
1661
1662 /// The gate that makes the flush safe: the errlog worker must never wait
1663 /// for its own next pass.
1664 ///
1665 /// A listener runs on the worker thread, and a listener that logs is
1666 /// ordinary — the IOC log client does it on a reconnect. C is protected by
1667 /// `isOkToBlock`, which is 0 for every `epicsThreadCreate` thread; here
1668 /// `enter_ioc_thread` clears the same flag for the worker. Without it this
1669 /// test does not fail, it hangs, so the wait is bounded and the failure is
1670 /// a timeout rather than a wedged process.
1671 #[test]
1672 #[serial(errlog_listeners)]
1673 fn a_listener_that_logs_does_not_wait_for_its_own_drain() {
1674 let logged = std::sync::atomic::AtomicBool::new(false);
1675 let id = errlog_add_listener(move |_| {
1676 if !logged.swap(true, std::sync::atomic::Ordering::SeqCst) {
1677 errlog_printf("from inside the listener\n");
1678 }
1679 });
1680
1681 let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
1682 let worker = std::thread::spawn(move || {
1683 errlog_printf("trigger\n");
1684 errlog_flush();
1685 let _ = done_tx.send(());
1686 });
1687 let finished = done_rx
1688 .recv_timeout(std::time::Duration::from_secs(10))
1689 .is_ok();
1690 if finished {
1691 worker.join().expect("the logging thread");
1692 }
1693 assert!(errlog_remove_listener(id));
1694 assert!(
1695 finished,
1696 "the worker flushed into itself and the producer never returned"
1697 );
1698 }
1699
1700 /// Boundary: a message at `maxMsgSize`. C cuts it to `maxMsgSize - 1`
1701 /// bytes and overwrites the tail with `<<TRUNCATED>>\n`
1702 /// (`errlog.c:148-154`) — the marker replaces text, it is not appended.
1703 #[test]
1704 fn an_oversized_message_is_cut_to_max_minus_one_with_the_marker_as_its_tail() {
1705 let mut q = test_queue();
1706 let max = q.max_msg_size;
1707 assert!(q.accept(&"a".repeat(max)));
1708 let stored = &q.log.entries[0];
1709 assert_eq!(stored.len(), max - 1, "C's `nchar = maxMsgSize - 1`");
1710 assert!(stored.ends_with(TRUNCATED), "{stored}");
1711
1712 // One byte under the limit is stored whole.
1713 let mut q = test_queue();
1714 assert!(q.accept(&"a".repeat(max - 1)));
1715 assert_eq!(q.log.entries[0].len(), max - 1);
1716 assert!(!q.log.entries[0].contains("TRUNCATED"));
1717 }
1718
1719 /// The two severity words expand exactly as `errlog.h:290-299` does:
1720 /// `ANSI_ESC_RED` / `ANSI_ESC_MAGENTA`, the word, `ANSI_ESC_RESET`.
1721 /// Measured against `softIoc` @`R7.0.10` with stderr redirected to a FILE,
1722 /// where a terminal-conditional word would have come out bare and does not
1723 /// — `dbLoadRecords("nosuch.db")` writes these bytes either way.
1724 #[test]
1725 fn the_severity_words_carry_c_s_escapes() {
1726 assert_eq!(ERL_ERROR, "\u{1b}[31;1mERROR\u{1b}[0m");
1727 // The halves and the whole are one definition, the way C's
1728 // `ANSI_RED("ERROR")` makes them one (`errlog.h:290`).
1729 assert_eq!(ERL_ERROR, format!("{ANSI_ESC_RED}ERROR{ANSI_ESC_RESET}"));
1730 assert_eq!(
1731 errlog_strip_ansi(&format!("{ANSI_ESC_RED}x{ANSI_ESC_RESET}")),
1732 "x"
1733 );
1734 assert_eq!(ERL_WARNING, "\u{1b}[35;1mWARNING\u{1b}[0m");
1735 // The pair errlog itself hands to `errlogStripANSI` — a wrapped word
1736 // and the word alone must be the same message to a log listener, or
1737 // the console and the listener disagree about what was logged.
1738 assert_eq!(errlog_strip_ansi(ERL_ERROR), "ERROR");
1739 assert_eq!(errlog_strip_ansi(ERL_WARNING), "WARNING");
1740 // `erl_warning` is the errlogPrintf-side twin: same bytes when the
1741 // console is a terminal, stripped by errlog when it is not. It may
1742 // never be substituted for the constant at a direct-`fprintf` site,
1743 // and this is the assertion that would fail if it were — under
1744 // `cargo test` stderr is not a terminal.
1745 assert_eq!(erl_warning(), "WARNING");
1746 }
1747
1748 /// The escapes `iocsh` and `softIoc` paint with, pinned to the macros they
1749 /// port. They used to be a second copy in `epics-base-rs`, where nothing
1750 /// compared them with `errlog.h`.
1751 #[test]
1752 fn the_span_escapes_are_errlog_h_s() {
1753 assert_eq!(ANSI_ESC_RED, "\u{1b}[31;1m"); // errlog.h:281
1754 assert_eq!(ANSI_ESC_BLUE, "\u{1b}[34;1m"); // errlog.h:284
1755 assert_eq!(ANSI_ESC_BOLD, "\u{1b}[1m"); // errlog.h:287
1756 assert_eq!(ANSI_ESC_UNDERLINE, "\u{1b}[4m"); // errlog.h:288
1757 assert_eq!(ANSI_ESC_RESET, "\u{1b}[0m"); // errlog.h:289
1758 // Every one of them is a CSI sequence `errlogStripANSI` removes, which
1759 // is what lets a log listener see the same message as the console.
1760 for esc in [
1761 ANSI_ESC_RED,
1762 ANSI_ESC_BLUE,
1763 ANSI_ESC_BOLD,
1764 ANSI_ESC_UNDERLINE,
1765 ] {
1766 assert_eq!(errlog_strip_ansi(&format!("{esc}x{ANSI_ESC_RESET}")), "x");
1767 }
1768 }
1769
1770 /// Boundary: `errlogStripANSI` (`errlog.c:269-313`). A listener always
1771 /// sees stripped text, so a colourised warning does not reach a site's
1772 /// log server as escape bytes.
1773 #[test]
1774 fn ansi_stripping_matches_the_c_state_machine() {
1775 assert_eq!(errlog_strip_ansi("plain"), "plain");
1776 assert_eq!(
1777 errlog_strip_ansi("cas \x1b[35;1mWARNING\x1b[0m: bind"),
1778 "cas WARNING: bind"
1779 );
1780 assert_eq!(
1781 errlog_strip_ansi("\x1b[?25lhidden\x1b[?25h"),
1782 "hidden",
1783 "`?` is part of a CSI parameter run"
1784 );
1785 assert_eq!(
1786 errlog_strip_ansi("a\x1bZb"),
1787 "aZb",
1788 "an ESC not followed by `[` loses only the ESC"
1789 );
1790 assert_eq!(
1791 errlog_strip_ansi("a\x1b"),
1792 "a",
1793 "a trailing lone ESC is dropped"
1794 );
1795 assert_eq!(
1796 errlog_strip_ansi("a\x1b[31"),
1797 "a",
1798 "a truncated CSI consumes the parameter run and stops"
1799 );
1800 assert_eq!(
1801 errlog_strip_ansi("\u{c624}\u{b958}\x1b[0m"),
1802 "\u{c624}\u{b958}",
1803 "only ASCII bytes are dropped, so UTF-8 survives"
1804 );
1805 }
1806
1807 /// The console owner appends nothing, which is the whole invariant: C
1808 /// writes an already-formatted line with `fprintf(console, "%s", base+1u)`
1809 /// (`errlog.c:795`, and `:170` on the at-exit path), so the caller's format
1810 /// string owns the framing. A message that ends in `\n` must not gain a
1811 /// second one and a message that does not must not gain one.
1812 #[test]
1813 fn the_console_writes_the_callers_bytes_and_appends_nothing() {
1814 let mut terminated = Vec::new();
1815 write_console(&mut terminated, "iocPause: IOC suspended\n");
1816 assert_eq!(terminated, b"iocPause: IOC suspended\n");
1817
1818 let mut bare = Vec::new();
1819 write_console(&mut bare, "dbConvertJSON: ");
1820 assert_eq!(bare, b"dbConvertJSON: ");
1821 }
1822
1823 /// The subscriber's skip is keyed on a target the `tracing` macros spell as
1824 /// a literal, so this is what stops the two spellings drifting apart.
1825 #[test]
1826 #[serial]
1827 fn the_errlog_target_is_the_one_the_macros_publish_on() {
1828 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1829 tracing::subscriber::with_default(CapturingSubscriber(seen.clone()), || {
1830 errlog_printf("x\n");
1831 });
1832 let lines = seen.lock().expect("sink").clone();
1833 assert!(
1834 lines
1835 .iter()
1836 .any(|l| l.contains(&format!("{ERRLOG_TARGET}:"))),
1837 "{lines:?}"
1838 );
1839 }
1840
1841 /// `ConsoleSubscriber` is not the errlog console. Rendering an errlog event
1842 /// would print C's bytes a second time behind a `LEVEL target:` prefix C
1843 /// never writes, so it declines them and takes every other target.
1844 #[test]
1845 #[serial]
1846 fn the_console_subscriber_declines_errlog_events_and_takes_the_rest() {
1847 let seen: std::sync::Arc<std::sync::Mutex<Vec<Option<String>>>> =
1848 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1849 let sink = std::sync::Arc::clone(&seen);
1850 struct Probe(std::sync::Arc<std::sync::Mutex<Vec<Option<String>>>>);
1851 impl tracing::Subscriber for Probe {
1852 fn enabled(&self, m: &tracing::Metadata<'_>) -> bool {
1853 *m.level() <= tracing::Level::INFO
1854 }
1855 fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
1856 Some(tracing::level_filters::LevelFilter::INFO)
1857 }
1858 fn event(&self, event: &tracing::Event<'_>) {
1859 self.0
1860 .lock()
1861 .expect("sink")
1862 .push(ConsoleSubscriber::line_for(event));
1863 }
1864 fn new_span(&self, _s: &tracing::span::Attributes<'_>) -> tracing::span::Id {
1865 tracing::span::Id::from_u64(1)
1866 }
1867 fn record(&self, _s: &tracing::span::Id, _v: &tracing::span::Record<'_>) {}
1868 fn record_follows_from(&self, _s: &tracing::span::Id, _f: &tracing::span::Id) {}
1869 fn enter(&self, _s: &tracing::span::Id) {}
1870 fn exit(&self, _s: &tracing::span::Id) {}
1871 }
1872 tracing::subscriber::with_default(Probe(sink), || {
1873 errlog_printf("iocPause: IOC suspended\n");
1874 tracing::warn!(target: "epics_base_rs::runtime", "a runtime line");
1875 });
1876
1877 let lines = seen.lock().expect("sink").clone();
1878 assert_eq!(lines.len(), 2, "{lines:?}");
1879 assert_eq!(lines[0], None, "an errlog event is write_console's");
1880 assert_eq!(
1881 lines[1].as_deref(),
1882 Some("WARN epics_base_rs::runtime: a runtime line"),
1883 "every other target still renders"
1884 );
1885 }
1886
1887 /// The console is ours in the two states an IOC runs in and nobody else's.
1888 /// An application that installed its own subscriber asked for its own
1889 /// formatting, so `write_console` must stay silent under one.
1890 #[test]
1891 #[serial]
1892 fn the_errlog_console_is_ours_only_with_nothing_listening_or_our_subscriber() {
1893 assert!(nothing_is_listening(), "no subscriber in a unit test");
1894 assert!(!console_subscriber_is_current());
1895
1896 tracing::subscriber::with_default(ConsoleSubscriber, || {
1897 assert!(!nothing_is_listening());
1898 assert!(
1899 console_subscriber_is_current(),
1900 "our own subscriber leaves the console to write_console"
1901 );
1902 });
1903
1904 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1905 tracing::subscriber::with_default(CapturingSubscriber(seen), || {
1906 assert!(!nothing_is_listening());
1907 assert!(
1908 !console_subscriber_is_current(),
1909 "a foreign subscriber owns its own console"
1910 );
1911 });
1912 }
1913
1914 /// The `tracing` half of the same bytes. An event is a record and its
1915 /// subscriber frames it, so exactly one trailing newline comes off and the
1916 /// newlines *inside* a multi-line message stay — `dbScan`'s over-run report
1917 /// (`dbScan.c:832-835`) is four lines and one errlog message.
1918 #[test]
1919 fn a_tracing_record_drops_one_trailing_newline_and_no_more() {
1920 assert_eq!(as_record("Starting iocInit\n"), "Starting iocInit");
1921 assert_eq!(as_record("dbConvertJSON: "), "dbConvertJSON: ");
1922 assert_eq!(as_record("a\n\n"), "a\n");
1923 assert_eq!(
1924 as_record("\ndbScan WARNING from 'x':\n\tOver-runs.\n"),
1925 "\ndbScan WARNING from 'x':\n\tOver-runs."
1926 );
1927 }
1928
1929 /// The invariant end to end: one `errlog_printf` reaches its listeners with
1930 /// the caller's bytes exactly (C hands `base+1u` to every listener,
1931 /// `errlog.c:687`) while the `tracing` record carries the same line once,
1932 /// unframed. Not asserted here: the process console itself, which cannot be
1933 /// read back in-process without redirecting `stderr` — the byte-exactness
1934 /// of its writer is pinned by
1935 /// [`the_console_writes_the_callers_bytes_and_appends_nothing`].
1936 #[test]
1937 #[serial(errlog_listeners)]
1938 fn the_callers_bytes_frame_every_errlog_sink() {
1939 let heard = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1940 let sink = std::sync::Arc::clone(&heard);
1941 let id = errlog_add_listener(move |m| sink.lock().expect("sink").push(m.to_string()));
1942
1943 let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1944 tracing::subscriber::with_default(CapturingSubscriber(events.clone()), || {
1945 errlog_printf("iocPause: IOC suspended\n");
1946 errlog_printf("dbConvertJSON: ");
1947 });
1948 errlog_flush();
1949 assert!(errlog_remove_listener(id));
1950
1951 let lines = heard.lock().expect("sink").clone();
1952 assert!(
1953 lines.iter().any(|l| l == "iocPause: IOC suspended\n"),
1954 "a listener sees the caller's terminator: {lines:?}"
1955 );
1956 assert!(
1957 lines.iter().any(|l| l == "dbConvertJSON: "),
1958 "and gains none when the caller supplied none: {lines:?}"
1959 );
1960
1961 let records = events.lock().expect("events").clone();
1962 assert!(
1963 records
1964 .iter()
1965 .any(|r| r == "INFO epics_base_rs::errlog: iocPause: IOC suspended"),
1966 "the tracing record is framed by its subscriber, not by the caller: {records:?}"
1967 );
1968 assert!(
1969 records
1970 .iter()
1971 .any(|r| r == "INFO epics_base_rs::errlog: dbConvertJSON: "),
1972 "{records:?}"
1973 );
1974 }
1975
1976 /// The row's own observable, half one: a registered listener sees the
1977 /// message. Delivery is on the errlog worker thread, so the test flushes
1978 /// the way C's `errlogFlush` does before looking.
1979 #[test]
1980 #[serial(errlog_listeners)]
1981 fn a_registered_listener_receives_every_message_after_a_flush() {
1982 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1983 let sink = std::sync::Arc::clone(&seen);
1984 let id = errlog_add_listener(move |m| sink.lock().expect("sink").push(m.to_string()));
1985
1986 errlog_printf("one");
1987 errlog_sev_printf(ErrlogSevEnum::Minor, "two");
1988 errlog_flush();
1989
1990 let lines = seen.lock().expect("sink").clone();
1991 assert!(lines.iter().any(|l| l == "one"), "{lines:?}");
1992 assert!(lines.iter().any(|l| l == "sevr=minor two"), "{lines:?}");
1993 assert!(errlog_remove_listener(id));
1994 }
1995
1996 /// Boundary: removal. C's `errlogRemoveListeners` returns how many nodes
1997 /// matched; a token matches at most one, so a second removal answers
1998 /// false and nothing further is delivered.
1999 #[test]
2000 #[serial(errlog_listeners)]
2001 fn a_removed_listener_stops_receiving_and_cannot_be_removed_twice() {
2002 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2003 let sink = std::sync::Arc::clone(&seen);
2004 let id = errlog_add_listener(move |m| sink.lock().expect("sink").push(m.to_string()));
2005 errlog_printf("before");
2006 errlog_flush();
2007 assert!(errlog_remove_listener(id));
2008 assert!(!errlog_remove_listener(id), "the token is spent");
2009 errlog_printf("after");
2010 errlog_flush();
2011
2012 let lines = seen.lock().expect("sink").clone();
2013 assert!(lines.iter().any(|l| l == "before"), "{lines:?}");
2014 assert!(
2015 !lines.iter().any(|l| l == "after"),
2016 "a removed listener must see nothing more: {lines:?}"
2017 );
2018 }
2019
2020 /// Boundary: the listener sees the message with its ANSI stripped, which
2021 /// is what C does before the listener loop (`errlog.c:678-681`).
2022 #[test]
2023 #[serial(errlog_listeners)]
2024 fn a_listener_sees_the_message_with_its_escapes_removed() {
2025 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2026 let sink = std::sync::Arc::clone(&seen);
2027 let id = errlog_add_listener(move |m| sink.lock().expect("sink").push(m.to_string()));
2028 errlog_printf("cas \x1b[35;1mWARNING\x1b[0m: bind");
2029 errlog_flush();
2030 assert!(errlog_remove_listener(id));
2031
2032 let lines = seen.lock().expect("sink").clone();
2033 assert!(lines.iter().any(|l| l == "cas WARNING: bind"), "{lines:?}");
2034 }
2035
2036 /// Boundary: a listener that removes itself from inside its own callback.
2037 /// C guards this with `active`/`removed` flags; the port drains against a
2038 /// snapshot, so the call cannot deadlock and the removal takes effect on
2039 /// the next message.
2040 #[test]
2041 #[serial(errlog_listeners)]
2042 fn a_listener_can_remove_itself_from_inside_its_own_callback() {
2043 let count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2044 let slot: std::sync::Arc<std::sync::Mutex<Option<ErrlogListenerId>>> =
2045 std::sync::Arc::new(std::sync::Mutex::new(None));
2046 let n = std::sync::Arc::clone(&count);
2047 let me = std::sync::Arc::clone(&slot);
2048 let id = errlog_add_listener(move |_m| {
2049 n.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2050 if let Some(id) = *me.lock().expect("slot") {
2051 errlog_remove_listener(id);
2052 }
2053 });
2054 *slot.lock().expect("slot") = Some(id);
2055
2056 errlog_printf("first");
2057 errlog_flush();
2058 errlog_printf("second");
2059 errlog_flush();
2060
2061 assert_eq!(
2062 count.load(std::sync::atomic::Ordering::Relaxed),
2063 1,
2064 "the self-removal must take effect, and must not have deadlocked"
2065 );
2066 }
2067
2068 /// Boundary: `eltc`. C returns 0 always; the port returns the previous
2069 /// setting so a caller can restore it, and the setting itself is what
2070 /// gates the console.
2071 #[test]
2072 #[serial(errlog_console)]
2073 fn eltc_reports_the_previous_setting_and_gates_the_console() {
2074 assert!(errlog_to_console(), "C initialises pvt.toConsole to TRUE");
2075 assert!(eltc(false), "the previous setting comes back");
2076 assert!(!errlog_to_console());
2077 assert!(!eltc(true));
2078 assert!(errlog_to_console());
2079 }
2080}
2081
2082/// Render `record` so it cannot end or split a line in a line-oriented log.
2083///
2084/// Every ASCII control character — `0x00..=0x1F` (which includes `\n`, `\r`
2085/// and NUL) and `0x7F` — becomes a printable `\xNN` escape. Everything else,
2086/// including all non-ASCII UTF-8, is passed through untouched, so the common
2087/// case allocates nothing.
2088///
2089/// # What this guarantees, and what it does not
2090///
2091/// It guarantees **line framing**: one record in, one line out, whatever the
2092/// record contains. That is the property an audit log needs — a reader must
2093/// not be able to mistake attacker-supplied text for a separate record.
2094///
2095/// It is deliberately **not** a reversible encoding: a backslash is left
2096/// alone, so a user string containing the four literal characters `\x0a` and
2097/// a real newline escape to the same bytes. Escaping backslashes would make
2098/// it reversible but would also corrupt any record that is already escaped —
2099/// a JSON record whose own encoder emitted `\n` would come back out as
2100/// `\\n`. Leaving backslash alone is what makes this safe to apply uniformly
2101/// at the writer, to every record, without the writer having to know which
2102/// renderer produced it.
2103///
2104/// Applying it to already-escaped output is a no-op, because a correct
2105/// encoder has already removed every raw control byte.
2106pub fn single_line(record: &str) -> std::borrow::Cow<'_, str> {
2107 fn must_escape(c: char) -> bool {
2108 (c as u32) < 0x20 || c as u32 == 0x7F
2109 }
2110 if !record.contains(must_escape) {
2111 return std::borrow::Cow::Borrowed(record);
2112 }
2113 let mut out = String::with_capacity(record.len() + 8);
2114 for c in record.chars() {
2115 if must_escape(c) {
2116 use std::fmt::Write;
2117 let _ = write!(out, "\\x{:02x}", c as u32);
2118 } else {
2119 out.push(c);
2120 }
2121 }
2122 std::borrow::Cow::Owned(out)
2123}
2124
2125#[cfg(test)]
2126mod single_line_tests {
2127 use super::single_line;
2128
2129 /// The framing guarantee, stated as a boundary sweep over every byte a
2130 /// record could carry rather than as a story about one attack.
2131 #[test]
2132 fn no_input_can_produce_more_than_one_line() {
2133 for b in 0u8..=0x7F {
2134 let c = b as char;
2135 let record = format!("a{c}b");
2136 let out = single_line(&record);
2137 assert_eq!(
2138 out.lines().count().max(1),
2139 1,
2140 "byte {b:#04x} split the record: {out:?}"
2141 );
2142 assert!(!out.contains('\n'), "byte {b:#04x} left a newline");
2143 assert!(!out.contains('\r'), "byte {b:#04x} left a carriage return");
2144 assert!(!out.contains('\0'), "byte {b:#04x} left a NUL");
2145 }
2146 }
2147
2148 #[test]
2149 fn exactly_the_ascii_control_range_is_escaped() {
2150 for b in 0u8..=0xFF {
2151 if b >= 0x80 {
2152 continue; // non-ASCII is tested as UTF-8 below
2153 }
2154 let c = b as char;
2155 let raw = c.to_string();
2156 let out = single_line(&raw);
2157 let escaped = out != raw;
2158 assert_eq!(
2159 escaped,
2160 b < 0x20 || b == 0x7F,
2161 "byte {b:#04x}: escaped={escaped}, expected={}",
2162 b < 0x20 || b == 0x7F
2163 );
2164 }
2165 assert_eq!(single_line("\n"), "\\x0a");
2166 assert_eq!(single_line("\r"), "\\x0d");
2167 assert_eq!(single_line("\0"), "\\x00");
2168 assert_eq!(single_line("\u{7f}"), "\\x7f");
2169 }
2170
2171 /// A clean record is returned borrowed — no allocation on the hot path.
2172 #[test]
2173 fn a_clean_record_is_passed_through_without_allocating() {
2174 let clean = "Apr 09 14:35:21 alice@opi-1 TEMP:setpoint 3.14 old=? OK";
2175 assert!(matches!(single_line(clean), std::borrow::Cow::Borrowed(_)));
2176 assert_eq!(single_line(clean), clean);
2177 // Non-ASCII survives intact: this escapes line framing, not Unicode.
2178 assert_eq!(single_line("설정값 μm"), "설정값 μm");
2179 }
2180
2181 /// Applying it to output that is already escaped must not corrupt it —
2182 /// this is what lets the writer apply ONE rule to every renderer instead
2183 /// of asking which renderer produced the record.
2184 #[test]
2185 fn it_is_a_no_op_on_already_escaped_output() {
2186 let json = r#"{"user":"a\nb","pv":"X"}"#;
2187 assert_eq!(single_line(json), json);
2188 assert_eq!(single_line(&single_line("a\nb")), single_line("a\nb"));
2189 }
2190}