refactor(firmware): centralize configuration and diagnostics

This commit is contained in:
Onur Özkan committed 2026-08-22 18:37:18 +03:00
1 parent 7048ed89d0
commit 387fdaad23
140 files changed
+6891 -4541

No files matched your search

+1
View File
@@ -15,5 +15,6 @@ default = []
facade = ["dep:log"]
[dependencies]
kaptan-config-derive = { path = "../kaptan-config-derive" }
kaptan-log-derive = { path = "../kaptan-log-derive" }
log = { version = "0.4.33", default-features = false, optional = true }
+60 -79
View File
@@ -6,15 +6,12 @@
use core::{
cell::UnsafeCell,
sync::atomic::{AtomicBool, AtomicU8, Ordering},
sync::atomic::{AtomicBool, Ordering},
};
use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError};
use crate::{
ErrorAdditionalInformation, ErrorPath, EventHub, LogFilter, LogFlags, LogText, PublishOutcome,
Severity,
};
use crate::{ErrorAdditionalInformation, ErrorPath, LogFlags, LogRecord, LogText, Severity};
/// Saturating, process-local accounting for the optional global facade.
///
@@ -24,8 +21,6 @@ use crate::{
pub struct FacadeHealth {
/// Calls rejected before formatting because their context is forbidden.
pub forbidden_context: u32,
/// Optional records excluded by the active verbosity cap.
pub filtered: u32,
/// Calls that lost the one-attempt ingress ownership race.
pub contended: u32,
/// Calls dropped because their bounded ingress lane was full.
@@ -36,7 +31,6 @@ pub struct FacadeHealth {
struct FacadeCounters {
forbidden_context: core::sync::atomic::AtomicU32,
filtered: core::sync::atomic::AtomicU32,
contended: core::sync::atomic::AtomicU32,
full: core::sync::atomic::AtomicU32,
truncated: core::sync::atomic::AtomicU32,
@@ -46,7 +40,6 @@ impl FacadeCounters {
const fn new() -> Self {
Self {
forbidden_context: core::sync::atomic::AtomicU32::new(0),
filtered: core::sync::atomic::AtomicU32::new(0),
contended: core::sync::atomic::AtomicU32::new(0),
full: core::sync::atomic::AtomicU32::new(0),
truncated: core::sync::atomic::AtomicU32::new(0),
@@ -71,7 +64,6 @@ impl FacadeCounters {
fn snapshot(&self) -> FacadeHealth {
FacadeHealth {
forbidden_context: self.forbidden_context.load(Ordering::Relaxed),
filtered: self.filtered.load(Ordering::Relaxed),
contended: self.contended.load(Ordering::Relaxed),
full: self.full.load(Ordering::Relaxed),
truncated: self.truncated.load(Ordering::Relaxed),
@@ -146,8 +138,8 @@ impl<const N: usize> IngressQueue<N> {
}
}
/// Fixed global-facade ingress. The normal task/runtime owner drains it into
/// an [`EventHub`] after startup.
/// Fixed global-facade ingress. The normal task/runtime owner takes each
/// transport-neutral record and fans it out to independently filtered sinks.
pub struct FacadeIngress<const N: usize> {
/// One dedicated critical slot keeps error records independent from the
/// bounded ordinary FIFO. It is deliberately one slot: producers never
@@ -157,7 +149,6 @@ pub struct FacadeIngress<const N: usize> {
critical_busy: AtomicBool,
queue: UnsafeCell<IngressQueue<N>>,
queue_busy: AtomicBool,
maximum_severity: AtomicU8,
counters: FacadeCounters,
}
@@ -167,29 +158,17 @@ pub struct FacadeIngress<const N: usize> {
unsafe impl<const N: usize> Sync for FacadeIngress<N> {}
impl<const N: usize> FacadeIngress<N> {
/// Creates empty fixed ingress with the boot-active verbosity cap.
pub const fn new(filter: LogFilter) -> Self {
/// Creates an empty fixed ingress.
pub const fn new() -> Self {
Self {
critical: UnsafeCell::new(None),
critical_busy: AtomicBool::new(false),
queue: UnsafeCell::new(IngressQueue::new()),
queue_busy: AtomicBool::new(false),
maximum_severity: AtomicU8::new(filter.maximum_severity() as u8),
counters: FacadeCounters::new(),
}
}
/// Updates the runtime-selected maximum non-error verbosity.
pub fn set_filter(&self, filter: LogFilter) {
self.maximum_severity
.store(filter.maximum_severity() as u8, Ordering::Release);
}
fn allows(&self, severity: Severity) -> bool {
severity <= Severity::Error
|| (severity as u8) <= self.maximum_severity.load(Ordering::Acquire)
}
fn push(&self, record: IngressRecord) {
if record.severity <= Severity::Error {
self.push_critical(record);
@@ -222,24 +201,23 @@ impl<const N: usize> FacadeIngress<N> {
self.counters.snapshot()
}
/// Moves at most one preformatted facade record into the central hub.
pub fn drain_one_into<const CRITICAL: usize, const BEST_EFFORT: usize>(
&self,
hub: &mut EventHub<CRITICAL, BEST_EFFORT>,
) -> Option<PublishOutcome> {
/// Takes at most one preformatted event for composition-level fan-out.
///
/// The ingress deliberately applies no sink policy. A caller may submit
/// the returned copy to MAVLink, blackbox, or any later sink without one
/// sink's verbosity selection suppressing another's event.
pub fn take(&self) -> Option<LogRecord> {
let record = match self.with_critical(Option::take) {
Some(Some(record)) => record,
Some(None) => self.with_queue(IngressQueue::pop).flatten()?,
None => return None,
};
Some(match record.body {
IngressBody::Log { message } => {
hub.publish_text(record.severity, record.flags, message)
}
IngressBody::Log { message } => LogRecord::new(record.severity, record.flags, message),
IngressBody::Error {
path,
additional_information,
} => hub.publish_error_text(record.flags, path, additional_information),
} => LogRecord::error(record.flags, path, additional_information),
})
}
@@ -278,6 +256,12 @@ impl<const N: usize> FacadeIngress<N> {
}
}
impl<const N: usize> Default for FacadeIngress<N> {
fn default() -> Self {
Self::new()
}
}
/// Installed standard logger. The context probe must return true for
/// ISR/panic/hard-fault/shutdown contexts; those calls are rejected before
/// [`Record::args`] is formatted.
@@ -296,7 +280,8 @@ impl<const N: usize> GlobalLogger<N> {
}
/// Installs this logger once for the process/image and enables all Rust
/// levels compiled into the image. Kaptan's own filter applies afterward.
/// levels compiled into the image. Each sink applies its policy after it
/// receives a record from the ingress.
pub fn install(&'static self) -> Result<(), SetLoggerError> {
log::set_logger(self)?;
log::set_max_level(LevelFilter::Trace);
@@ -305,18 +290,11 @@ impl<const N: usize> GlobalLogger<N> {
}
impl<const N: usize> Log for GlobalLogger<N> {
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
fn enabled(&self, _: &Metadata<'_>) -> bool {
if (self.forbidden_context)() {
FacadeCounters::increment(&self.ingress.counters.forbidden_context);
return false;
}
let Some(severity) = severity(metadata.level()) else {
return false;
};
if !self.ingress.allows(severity) {
FacadeCounters::increment(&self.ingress.counters.filtered);
return false;
}
true
}
@@ -325,13 +303,7 @@ impl<const N: usize> Log for GlobalLogger<N> {
FacadeCounters::increment(&self.ingress.counters.forbidden_context);
return;
}
let Some(severity) = severity(record.level()) else {
return;
};
if !self.ingress.allows(severity) {
FacadeCounters::increment(&self.ingress.counters.filtered);
return;
}
let severity = severity(record.level());
let flags = if record.level() == Level::Trace {
LogFlags::TRACE
} else {
@@ -367,12 +339,12 @@ impl<const N: usize> Log for GlobalLogger<N> {
fn flush(&self) {}
}
const fn severity(level: Level) -> Option<Severity> {
const fn severity(level: Level) -> Severity {
match level {
Level::Error => Some(Severity::Error),
Level::Warn => Some(Severity::Warning),
Level::Info => Some(Severity::Info),
Level::Debug | Level::Trace => Some(Severity::Debug),
Level::Error => Severity::Error,
Level::Warn => Severity::Warning,
Level::Info => Severity::Info,
Level::Debug | Level::Trace => Severity::Debug,
}
}
@@ -382,10 +354,10 @@ mod tests {
use std::sync::Once;
use super::{FacadeIngress, GlobalLogger};
use crate::{EventHub, LogFilter, RecordChannel, Severity};
use crate::{RecordChannel, Severity};
use log::{Level, Log, Metadata, Record};
static GLOBAL_INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
static GLOBAL_INGRESS: FacadeIngress<2> = FacadeIngress::new();
static GLOBAL_LOGGER: GlobalLogger<2> = GlobalLogger::new(&GLOBAL_INGRESS, never_forbidden);
static INSTALL_GLOBAL: Once = Once::new();
@@ -399,7 +371,7 @@ mod tests {
#[test]
fn rejects_forbidden_context_before_formatting() {
static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
static INGRESS: FacadeIngress<2> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, always_forbidden);
let metadata = Metadata::builder()
.level(Level::Error)
@@ -408,19 +380,31 @@ mod tests {
assert!(!logger.enabled(&metadata));
assert_eq!(INGRESS.health().forbidden_context, 1);
static FILTERED: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Info));
let filtered_logger = GlobalLogger::new(&FILTERED, never_forbidden);
static UNFILTERED: FacadeIngress<2> = FacadeIngress::new();
let unfiltered_logger = GlobalLogger::new(&UNFILTERED, never_forbidden);
let debug = Metadata::builder()
.level(Level::Debug)
.target("test")
.build();
assert!(!filtered_logger.enabled(&debug));
assert_eq!(FILTERED.health().filtered, 1);
assert!(unfiltered_logger.enabled(&debug));
unfiltered_logger.log(
&Record::builder()
.metadata(debug)
.args(format_args!("sink decides whether to keep this"))
.build(),
);
assert_eq!(
UNFILTERED
.take()
.expect("debug event reaches the unfiltered ingress")
.severity(),
Severity::Debug
);
}
#[test]
fn formats_once_then_drains_into_priority_hub() {
static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
fn formats_once_then_exposes_a_transport_neutral_record() {
static INGRESS: FacadeIngress<2> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
let record = Record::builder()
.metadata(
@@ -432,9 +416,7 @@ mod tests {
.args(format_args!("link failed"))
.build();
logger.log(&record);
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
assert!(INGRESS.drain_one_into(&mut hub).is_some());
let pending = hub.peek().unwrap();
let pending = INGRESS.take().expect("facade event retained");
assert_eq!(pending.error_path().unwrap().as_str(), "test");
assert_eq!(
pending.additional_information().unwrap().as_str(),
@@ -456,11 +438,10 @@ mod tests {
fn standard_log_macros_enqueue_bounded_targeted_text() {
INSTALL_GLOBAL.call_once(|| GLOBAL_LOGGER.install().expect("one global test logger"));
log::info!("armed configuration loaded");
let mut hub = EventHub::<1, 2>::new(LogFilter::default());
assert!(GLOBAL_INGRESS.drain_one_into(&mut hub).is_some());
assert!(
hub.peek()
.unwrap()
GLOBAL_INGRESS
.take()
.expect("macro event retained")
.message()
.unwrap()
.as_str()
@@ -470,7 +451,7 @@ mod tests {
#[test]
fn forbidden_context_skips_argument_formatting() {
static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
static INGRESS: FacadeIngress<2> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, always_forbidden);
logger.log(
&Record::builder()
@@ -488,7 +469,7 @@ mod tests {
#[test]
fn bounded_ingress_exposes_local_drop_and_truncation_accounting() {
static INGRESS: FacadeIngress<1> = FacadeIngress::new(LogFilter::new(Severity::Info));
static INGRESS: FacadeIngress<1> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
logger.log(
&Record::builder()
@@ -531,7 +512,7 @@ mod tests {
#[test]
fn full_optional_ingress_retains_an_error_for_the_error_channel() {
static INGRESS: FacadeIngress<1> = FacadeIngress::new(LogFilter::new(Severity::Info));
static INGRESS: FacadeIngress<1> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
logger.log(
&Record::builder()
@@ -556,9 +537,9 @@ mod tests {
.build(),
);
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
assert!(INGRESS.drain_one_into(&mut hub).is_some());
let error = hub.peek().expect("error retained ahead of optional text");
let error = INGRESS
.take()
.expect("error retained ahead of optional text");
assert_eq!(error.channel(), RecordChannel::Error);
assert_eq!(error.error_path().unwrap().as_str(), "test");
assert_eq!(error.additional_information().unwrap().as_str(), "critical");
+59 -19
View File
@@ -2,37 +2,77 @@
use crate::Severity;
/// Immutable policy for best-effort log admission.
/// Immutable policy for one log sink.
///
/// Error/critical/vital records are never filtered by this type; callers use
/// their dedicated delivery lanes for those records.
/// Filtering belongs to a sink rather than to event creation: MAVLink and a
/// future blackbox may therefore select different policies without either
/// sink suppressing the other's events.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LogFilter {
maximum_severity: Severity,
pub enum LogFilter {
/// The sink receives no log events, including errors.
Disabled,
/// The sink receives this level and every more severe level.
UpTo(Severity),
}
impl LogFilter {
/// Creates a best-effort verbosity cap.
pub const fn new(maximum_severity: Severity) -> Self {
Self { maximum_severity }
/// Creates a disabled sink policy.
pub const fn disabled() -> Self {
Self::Disabled
}
/// The selected MAVLink-compatible severity cap.
///
/// The value is exact on the wire: in particular, `NOTICE` does not
/// silently permit `INFO` records.
pub const fn maximum_severity(self) -> Severity {
self.maximum_severity
/// Creates a sink policy with one inclusive verbosity cap.
pub const fn up_to(maximum_severity: Severity) -> Self {
Self::UpTo(maximum_severity)
}
/// Returns true if a best-effort record should be retained.
/// The selected MAVLink-compatible severity cap, or `None` when disabled.
pub const fn maximum_severity(self) -> Option<Severity> {
match self {
Self::Disabled => None,
Self::UpTo(maximum_severity) => Some(maximum_severity),
}
}
/// Returns true if this sink accepts the event.
pub const fn allows(self, severity: Severity) -> bool {
(severity as u8) <= (self.maximum_severity as u8)
match self {
Self::Disabled => false,
Self::UpTo(maximum_severity) => severity.wire_value() <= maximum_severity.wire_value(),
}
}
}
impl Default for LogFilter {
fn default() -> Self {
Self::new(Severity::Info)
#[cfg(test)]
mod tests {
use super::LogFilter;
use crate::Severity;
#[test]
fn disabled_policy_rejects_every_severity() {
let filter = LogFilter::disabled();
for severity in [
Severity::Emergency,
Severity::Alert,
Severity::Critical,
Severity::Error,
Severity::Warning,
Severity::Notice,
Severity::Info,
Severity::Debug,
] {
assert!(!filter.allows(severity));
}
assert_eq!(filter.maximum_severity(), None);
}
#[test]
fn level_policy_is_inclusive_and_rejects_more_verbose_events() {
let filter = LogFilter::up_to(Severity::Info);
assert!(filter.allows(Severity::Emergency));
assert!(filter.allows(Severity::Error));
assert!(filter.allows(Severity::Info));
assert!(!filter.allows(Severity::Debug));
assert_eq!(filter.maximum_severity(), Some(Severity::Info));
}
}
+197 -160
View File
@@ -4,7 +4,7 @@ use core::fmt;
use crate::{
DeliveryClass, ErrorAdditionalInformation, ErrorPath, ErrorReport, LogFilter, LogFlags,
LogRecord, LogText, Severity, StaticError,
LogRecord, LogText, Severity, StaticError, StaticErrorMailbox,
};
/// Outcome from one bounded diagnostic admission attempt.
@@ -12,9 +12,7 @@ use crate::{
pub enum PublishOutcome {
/// The record was retained.
Queued,
/// An adjacent equivalent record absorbed this occurrence.
Coalesced,
/// An ordinary log record was excluded by the configured verbosity cap.
/// A record was excluded by this sink's configured policy.
Filtered,
/// A best-effort record was replaced by newer optional information.
ReplacedOldest,
@@ -26,37 +24,10 @@ pub enum PublishOutcome {
/// record to its lower transport.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TransportAdmission {
/// The lower transport retained the record, so it may leave the hub.
/// The lower transport retained the record, so it may leave the queue.
Accepted,
/// The lower transport did not accept it yet; retain it for retry.
Retry,
/// The lower transport will not accept optional detail. This can only
/// discard a best-effort record; critical records remain queued.
DropBestEffort,
}
/// Saturating, process-local accounting for bounded diagnostic admission.
///
/// These counters intentionally are not part of `KAPTAN_LOG` or
/// `KAPTAN_ERROR`: neither wire message is a metrics protocol.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct HubHealth {
/// Optional records rejected by the active verbosity cap.
pub filtered: u32,
/// Adjacent records folded into an existing entry.
pub coalesced: u32,
/// Optional entries replaced because their bounded lane was full.
pub best_effort_replaced: u32,
/// Error entries rejected because their bounded critical lane was full.
pub critical_rejected: u32,
/// Optional entries the lower transport refused permanently.
pub best_effort_transport_dropped: u32,
}
impl HubHealth {
fn increment(value: &mut u32) {
*value = value.saturating_add(1);
}
}
struct Lane<const N: usize> {
@@ -76,15 +47,6 @@ impl<const N: usize> Lane<N> {
}
fn publish(&mut self, record: LogRecord, replace_oldest: bool) -> PublishOutcome {
if self.len != 0 {
let tail = (self.head + self.len - 1) % N;
if let Some(previous) = self.entries[tail].as_mut()
&& previous.is_equivalent_to(&record)
{
previous.increment_occurrences();
return PublishOutcome::Coalesced;
}
}
if self.len == N {
if replace_oldest {
self.entries[self.head] = Some(record);
@@ -99,6 +61,10 @@ impl<const N: usize> Lane<N> {
PublishOutcome::Queued
}
const fn is_full(&self) -> bool {
self.len == N
}
fn peek(&self) -> Option<LogRecord> {
self.entries.get(self.head).copied().flatten()
}
@@ -114,39 +80,78 @@ impl<const N: usize> Lane<N> {
}
}
/// One bounded priority hub. It owns no global logger, MAVLink state, or DMA.
pub struct EventHub<const CRITICAL: usize, const BEST_EFFORT: usize> {
/// One bounded priority queue owned by a log sink.
///
/// It owns no global logger, MAVLink state, DMA, filesystem, or other sink.
pub struct LogSinkQueue<const CRITICAL: usize, const BEST_EFFORT: usize> {
filter: LogFilter,
critical: Lane<CRITICAL>,
best_effort: Lane<BEST_EFFORT>,
health: HubHealth,
}
impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EFFORT> {
/// Creates one hub with its immutable boot-active verbosity cap.
impl<const CRITICAL: usize, const BEST_EFFORT: usize> LogSinkQueue<CRITICAL, BEST_EFFORT> {
/// Creates one sink queue with its immutable boot-active policy.
pub const fn new(filter: LogFilter) -> Self {
Self {
filter,
critical: Lane::new(),
best_effort: Lane::new(),
health: HubHealth {
filtered: 0,
coalesced: 0,
best_effort_replaced: 0,
critical_rejected: 0,
best_effort_transport_dropped: 0,
},
}
}
/// Immutable best-effort verbosity policy.
/// Immutable sink policy.
pub const fn filter(&self) -> LogFilter {
self.filter
}
/// Process-local bounded-admission accounting.
pub const fn health(&self) -> HubHealth {
self.health
/// Whether an error source may be consumed without losing an enabled
/// critical record. Filtered errors may be consumed intentionally.
pub const fn can_publish_error(&self) -> bool {
!self.filter.allows(Severity::Error) || !self.critical.is_full()
}
/// Consumes at most one source-local static-error mailbox in round-robin
/// order. An enabled source remains untouched while the critical lane is
/// full; a filtered source is consumed intentionally.
pub fn drain_next_error_mailbox<const SOURCES: usize>(
&mut self,
mailboxes: [&StaticErrorMailbox; SOURCES],
cursor: &mut u8,
) -> bool {
assert!(
SOURCES != 0 && SOURCES <= u8::MAX as usize,
"error mailbox fan-in must fit its u8 cursor"
);
if !self.can_publish_error() {
return false;
}
if usize::from(*cursor) >= SOURCES {
*cursor = 0;
}
let mut checked = 0;
while checked < SOURCES {
let index = usize::from(*cursor);
*cursor = if index + 1 == SOURCES { 0 } else { *cursor + 1 };
checked += 1;
let (error, coalesced) = mailboxes[index].take();
let Some(error) = error else {
continue;
};
let flags = if coalesced {
LogFlags::COALESCED
} else {
LogFlags::NONE
};
let outcome = self.publish_error_static(flags, error);
debug_assert!(matches!(
outcome,
PublishOutcome::Queued | PublishOutcome::Filtered
));
return true;
}
false
}
/// Formats and retains one ordinary task/worker diagnostic. Error-level
@@ -164,7 +169,7 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
/// Retains one static derived error without formatting or exposing source
/// values. The adapter maps it to `KAPTAN_ERROR`.
pub fn publish_error<E: ErrorReport>(&mut self, flags: LogFlags, error: &E) -> PublishOutcome {
self.publish_error_static(flags, error.static_error())
self.publish_record(LogRecord::from_error(flags, error))
}
/// Retains one static mailbox error. This is the ISR-safe counterpart to
@@ -174,16 +179,13 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
flags: LogFlags,
error: &'static StaticError,
) -> PublishOutcome {
self.publish_error_text(
flags,
ErrorPath::from_message(error.path()),
ErrorAdditionalInformation::from_message(error.additional_information()),
)
self.publish_record(LogRecord::from_static_error(flags, error))
}
/// Retains a preformatted error identity and optional detail. Both
/// components stay transport-neutral here; the MAVLink adapter enforces
/// its independent on-wire capacities.
/// transport-neutral components already have the exact common diagnostic
/// envelope capacities, so a sink adapter need not retain or clip them
/// again.
pub fn publish_error_text(
&mut self,
flags: LogFlags,
@@ -194,38 +196,29 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
}
/// Retains one preformatted bounded text payload. Error-or-higher
/// severities are independently retained and sent as `KAPTAN_ERROR`.
/// severities are independently encoded as `KAPTAN_ERROR`.
pub fn publish_text(
&mut self,
severity: Severity,
flags: LogFlags,
text: LogText,
) -> PublishOutcome {
let delivery = delivery_for(severity);
if delivery == DeliveryClass::BestEffort && !self.filter.allows(severity) {
HubHealth::increment(&mut self.health.filtered);
return PublishOutcome::Filtered;
}
self.publish_record(LogRecord::new(severity, flags, text))
}
fn publish_record(&mut self, record: LogRecord) -> PublishOutcome {
/// Applies this sink's policy and retains one already-constructed event.
///
/// Composition may submit the same transport-neutral record to multiple
/// sink queues without coupling event creation to any sink policy.
pub fn publish_record(&mut self, record: LogRecord) -> PublishOutcome {
if !self.filter.allows(record.severity()) {
return PublishOutcome::Filtered;
}
let delivery = record.delivery();
let outcome = match delivery {
match delivery {
DeliveryClass::Critical => self.critical.publish(record, false),
DeliveryClass::BestEffort => self.best_effort.publish(record, true),
};
match outcome {
PublishOutcome::Coalesced => HubHealth::increment(&mut self.health.coalesced),
PublishOutcome::ReplacedOldest => {
HubHealth::increment(&mut self.health.best_effort_replaced);
}
PublishOutcome::RejectedFull => {
HubHealth::increment(&mut self.health.critical_rejected);
}
PublishOutcome::Queued | PublishOutcome::Filtered => {}
}
outcome
}
/// Highest-priority record without relinquishing it.
@@ -233,9 +226,9 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
self.critical.peek().or_else(|| self.best_effort.peek())
}
/// Submits the current record through the sole telemetry owner. The hub
/// removes it only after `Accepted`, and only permits `DropBestEffort` for
/// optional detail. This keeps encoder/admission retry semantics local.
/// Submits the current record through the sink owner and removes it only
/// after `Accepted`. This keeps encoder/admission retry semantics local
/// for every severity.
pub fn submit_next<R>(
&mut self,
submit: impl FnOnce(&LogRecord) -> (TransportAdmission, R),
@@ -246,13 +239,7 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
TransportAdmission::Accepted => {
let _ = self.pop();
}
TransportAdmission::DropBestEffort
if record.delivery() == DeliveryClass::BestEffort =>
{
let _ = self.best_effort.pop();
HubHealth::increment(&mut self.health.best_effort_transport_dropped);
}
TransportAdmission::Retry | TransportAdmission::DropBestEffort => {}
TransportAdmission::Retry => {}
}
Some(result)
}
@@ -262,27 +249,30 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
}
}
fn delivery_for(severity: Severity) -> DeliveryClass {
if severity <= Severity::Error {
DeliveryClass::Critical
} else {
DeliveryClass::BestEffort
}
}
#[cfg(test)]
mod tests {
use super::{EventHub, PublishOutcome, TransportAdmission};
use super::{LogSinkQueue, PublishOutcome, TransportAdmission};
use crate::{
DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogText,
RecordChannel, Severity, StaticError,
DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogRecord,
LogText, RecordChannel, Severity, StaticError,
};
#[test]
fn errors_bypass_filter_but_debug_does_not() {
let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info));
fn sink_policy_applies_to_errors_and_ordinary_logs() {
let mut disabled = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
assert_eq!(
hub.publish_text(
disabled.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("error")
),
PublishOutcome::Filtered
);
assert!(disabled.peek().is_none());
let mut queue = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
assert_eq!(
queue.publish_text(
Severity::Debug,
LogFlags::NONE,
LogText::from_message("debug")
@@ -290,43 +280,110 @@ mod tests {
PublishOutcome::Filtered
);
assert_eq!(
hub.publish_text(
queue.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("error")
),
PublishOutcome::Queued
);
let record = hub.peek().unwrap();
let record = queue.peek().unwrap();
assert_eq!(record.error_path().unwrap().as_str(), "error");
assert!(record.additional_information().unwrap().is_empty());
assert_eq!(record.channel(), RecordChannel::Error);
}
#[test]
fn static_messages_and_repeats_are_bounded() {
static FAULT: StaticError = StaticError::new("driver::Fault");
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
fn full_critical_lane_backpressures_only_an_enabled_error_source() {
let mut enabled = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
assert!(enabled.can_publish_error());
assert_eq!(
hub.publish_error_static(LogFlags::NONE, &FAULT),
enabled.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("pending")
),
PublishOutcome::Queued
);
assert!(!enabled.can_publish_error());
let disabled = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
assert!(disabled.can_publish_error());
}
#[test]
fn mailbox_fan_in_survives_backpressure_and_is_round_robin() {
static FIRST: StaticError = StaticError::new("first");
static SECOND: StaticError = StaticError::new("second");
static THIRD: StaticError = StaticError::new("third");
let first = crate::StaticErrorMailbox::new();
let second = crate::StaticErrorMailbox::new();
let third = crate::StaticErrorMailbox::new();
let mailboxes = [&first, &second, &third];
first.record(&FIRST);
second.record(&SECOND);
third.record(&THIRD);
let mut queue = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
let mut cursor = 0;
assert!(queue.drain_next_error_mailbox(mailboxes, &mut cursor));
assert_eq!(
queue.peek().unwrap().error_path().unwrap().as_str(),
"first"
);
first.record(&FIRST);
assert!(!queue.drain_next_error_mailbox(mailboxes, &mut cursor));
assert_eq!(
queue.peek().unwrap().error_path().unwrap().as_str(),
"first"
);
let mut expect = |path| {
assert_eq!(queue.peek().unwrap().error_path().unwrap().as_str(), path);
assert_eq!(
queue.submit_next(|_| (TransportAdmission::Accepted, ())),
Some(())
);
assert!(queue.drain_next_error_mailbox(mailboxes, &mut cursor));
};
expect("first");
expect("second");
expect("third");
assert_eq!(
queue.peek().unwrap().error_path().unwrap().as_str(),
"first"
);
}
#[test]
fn identical_events_remain_distinct_records() {
static FAULT: StaticError = StaticError::new("driver::Fault");
let mut queue = LogSinkQueue::<2, 1>::new(LogFilter::up_to(Severity::Debug));
assert_eq!(
queue.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Queued
);
assert_eq!(
hub.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Coalesced
queue.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Queued
);
let record = hub.peek().unwrap();
assert_eq!(record.occurrences, 2);
assert!(record.flags().contains(LogFlags::COALESCED));
let record = queue.peek().unwrap();
assert!(!record.flags().contains(LogFlags::COALESCED));
assert_eq!(record.error_path().unwrap().as_str(), "driver::Fault");
assert!(record.additional_information().unwrap().is_empty());
assert_eq!(
queue.submit_next(|_| (TransportAdmission::Accepted, ())),
Some(())
);
assert_eq!(queue.peek(), Some(record));
}
#[test]
fn static_error_detail_needs_no_producer_formatting() {
static FAULT: StaticError =
StaticError::with_additional_information("driver::Dma::Fault", "stream 7");
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
assert_eq!(
hub.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Queued
@@ -341,7 +398,7 @@ mod tests {
#[test]
fn error_identity_and_detail_are_independent_record_components() {
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
let _ = hub.publish_error_text(
LogFlags::NONE,
ErrorPath::from_message("driver::Icm42688::Transfer"),
@@ -360,15 +417,15 @@ mod tests {
}
#[test]
fn retry_retains_critical_and_only_best_effort_can_be_discarded() {
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
fn retry_retains_every_severity() {
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
let _ = hub.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("fault"),
);
assert_eq!(
hub.submit_next(|_| (TransportAdmission::DropBestEffort, ())),
hub.submit_next(|_| (TransportAdmission::Retry, ())),
Some(())
);
assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::Critical);
@@ -384,50 +441,30 @@ mod tests {
LogText::from_message("detail"),
);
assert_eq!(
hub.submit_next(|_| (TransportAdmission::DropBestEffort, ())),
hub.submit_next(|_| (TransportAdmission::Retry, ())),
Some(())
);
assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::BestEffort);
assert_eq!(
hub.submit_next(|_| (TransportAdmission::Accepted, ())),
Some(())
);
assert!(hub.peek().is_none());
assert_eq!(hub.health().best_effort_transport_dropped, 1);
}
#[test]
fn admission_losses_are_retained_as_local_saturating_accounting() {
let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info));
let _ = hub.publish_text(
Severity::Debug,
LogFlags::NONE,
LogText::from_message("filtered"),
);
let _ = hub.publish_text(
fn independent_sink_policies_do_not_suppress_each_other() {
let record = LogRecord::new(
Severity::Info,
LogFlags::NONE,
LogText::from_message("first optional"),
);
let _ = hub.publish_text(
Severity::Info,
LogFlags::NONE,
LogText::from_message("newer optional"),
);
let _ = hub.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("first error"),
);
let _ = hub.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("second error"),
);
assert_eq!(
hub.health(),
super::HubHealth {
filtered: 1,
coalesced: 0,
best_effort_replaced: 1,
critical_rejected: 1,
best_effort_transport_dropped: 0,
}
LogText::from_message("flight ready"),
);
let mut mavlink = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
let mut blackbox = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
assert_eq!(mavlink.publish_record(record), PublishOutcome::Filtered);
assert_eq!(blackbox.publish_record(record), PublishOutcome::Queued);
assert!(mavlink.peek().is_none());
assert_eq!(blackbox.peek(), Some(record));
}
}
+10 -15
View File
@@ -2,7 +2,7 @@
/// MAVLink-compatible severity. Numeric values deliberately match
/// `MAV_SEVERITY`.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, kaptan_config_derive::ConfigEnum)]
#[repr(u8)]
pub enum Severity {
/// System is unusable.
@@ -23,19 +23,14 @@ pub enum Severity {
Debug = 7,
}
impl Severity {
/// Converts the stable MAVLink numeric representation.
pub const fn try_from_wire(value: u8) -> Option<Self> {
match value {
0 => Some(Self::Emergency),
1 => Some(Self::Alert),
2 => Some(Self::Critical),
3 => Some(Self::Error),
4 => Some(Self::Warning),
5 => Some(Self::Notice),
6 => Some(Self::Info),
7 => Some(Self::Debug),
_ => None,
}
#[cfg(test)]
mod tests {
use core::mem::size_of;
use super::Severity;
#[test]
fn severity_is_one_byte() {
assert_eq!(size_of::<Severity>(), 1);
}
}
+10 -6
View File
@@ -1,8 +1,9 @@
//! Bounded, transport-neutral runtime diagnostics for Kaptan.
//!
//! This crate deliberately contains no MAVLink, board, filesystem, clock, or
//! configuration dependency. Flight code reports bounded text records to a
//! hub; a platform-owned adapter may later encode them for a link.
//! configuration dependency. Flight code reports bounded events to
//! independently filtered sink queues; a platform-owned adapter may later
//! encode one queue for a link.
//! Reporting is observational and must never influence flight control or
//! actuator authority.
@@ -15,8 +16,8 @@ extern crate std;
pub mod crash;
#[cfg(feature = "facade")]
pub mod facade;
pub mod filter;
pub mod hub;
mod filter;
mod hub;
pub mod level;
pub mod mailbox;
pub mod report;
@@ -25,13 +26,16 @@ pub mod text;
pub use crash::{CrashKind, CrashRecord, CrashRecordError};
pub use filter::LogFilter;
pub use hub::{EventHub, HubHealth, PublishOutcome, TransportAdmission};
pub use hub::{LogSinkQueue, PublishOutcome, TransportAdmission};
pub use kaptan_log_derive::KaptanError;
pub use level::Severity;
pub use mailbox::StaticErrorMailbox;
pub use report::{DeliveryClass, ErrorReport, LogFlags, LogRecord, RecordChannel, StaticError};
pub use shutdown::{ShutdownReason, ShutdownReport};
pub use text::{ErrorAdditionalInformation, ErrorPath, LogText, MAXIMUM_LOG_TEXT_BYTES};
pub use text::{
ErrorAdditionalInformation, ErrorPath, LogText, MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES,
MAXIMUM_ERROR_PATH_BYTES, MAXIMUM_LOG_TEXT_BYTES,
};
#[cfg(feature = "facade")]
pub use facade::FacadeHealth;
+57 -40
View File
@@ -2,74 +2,71 @@
use core::{
ptr,
sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering},
sync::atomic::{AtomicPtr, Ordering},
};
use crate::StaticError;
/// One source-local, single-attempt static-fault mailbox.
/// One source-local static-fault mailbox with fixed bounded atomic attempts.
///
/// Producer contexts retain the first pending message and mark later ones as
/// coalesced. They never loop, format, allocate, lock, or encode a frame. A
/// worker drains the exact message pointer and flag later.
pub struct StaticErrorMailbox {
error: AtomicPtr<StaticError>,
coalesced: AtomicBool,
coalesced_total: AtomicU32,
/// Pending pointer with its low alignment bit used as the coalesced flag.
state: AtomicPtr<StaticError>,
}
const COALESCED_TAG: usize = 1;
const _: () = assert!(core::mem::align_of::<StaticError>() > COALESCED_TAG);
impl StaticErrorMailbox {
/// Creates an empty mailbox.
pub const fn new() -> Self {
Self {
error: AtomicPtr::new(ptr::null_mut()),
coalesced: AtomicBool::new(false),
coalesced_total: AtomicU32::new(0),
state: AtomicPtr::new(ptr::null_mut()),
}
}
/// Records one static message with one bounded atomic attempt.
/// Records one static message with at most two fixed atomic attempts: one
/// to retain it, then one to tag an already pending observation.
pub fn record(&self, error: &'static StaticError) {
if self
.error
.compare_exchange(
ptr::null_mut(),
core::ptr::from_ref(error).cast_mut(),
Ordering::AcqRel,
Ordering::Acquire,
)
.is_err()
let error = core::ptr::from_ref(error).cast_mut();
if let Err(pending) =
self.state
.compare_exchange(ptr::null_mut(), error, Ordering::AcqRel, Ordering::Acquire)
{
self.coalesced.store(true, Ordering::Relaxed);
increment_saturating(&self.coalesced_total);
self.mark_coalesced(pending);
}
}
/// Marks only the exact pending pointer observed by `record`. If a worker
/// drained it concurrently, the single CAS fails without leaving a flag
/// that could attach to a later error.
fn mark_coalesced(&self, pending: *mut StaticError) {
if pending.addr() & COALESCED_TAG != 0 {
return;
}
let tagged = pending.map_addr(|address| address | COALESCED_TAG);
let _ = self
.state
.compare_exchange(pending, tagged, Ordering::AcqRel, Ordering::Acquire);
}
/// Removes the retained first message and its coalesced marker.
pub fn take(&self) -> (Option<&'static StaticError>, bool) {
let error = self.error.swap(ptr::null_mut(), Ordering::AcqRel);
let coalesced = self.coalesced.swap(false, Ordering::AcqRel);
let state = self.state.swap(ptr::null_mut(), Ordering::AcqRel);
if state.is_null() {
return (None, false);
}
let coalesced = state.addr() & COALESCED_TAG != 0;
let error = state.map_addr(|address| address & !COALESCED_TAG);
// SAFETY: `record` stores only pointers to static messages and the
// atomic swap transfers the exact pointer to this one worker.
// atomic swap transfers the exact pointer to this one worker. The low
// tag bit is cleared before dereferencing the aligned pointer.
let error = unsafe { error.as_ref() };
(error, coalesced)
}
/// Total later-fault coalesces since startup. This is local producer
/// accounting; wire diagnostics remain human-facing envelopes.
pub fn coalesced_total(&self) -> u32 {
self.coalesced_total.load(Ordering::Relaxed)
}
}
/// One bounded atomic increment: diagnostics may undercount a simultaneous
/// producer collision but never loop, block, or wrap back to zero.
fn increment_saturating(counter: &AtomicU32) {
let current = counter.load(Ordering::Relaxed);
if current != u32::MAX {
let _ =
counter.compare_exchange(current, current + 1, Ordering::Relaxed, Ordering::Relaxed);
}
}
impl Default for StaticErrorMailbox {
@@ -80,6 +77,8 @@ impl Default for StaticErrorMailbox {
#[cfg(test)]
mod tests {
use core::sync::atomic::Ordering;
use super::StaticErrorMailbox;
use crate::StaticError;
@@ -91,7 +90,25 @@ mod tests {
mailbox.record(&FIRST);
mailbox.record(&SECOND);
assert_eq!(mailbox.take(), (Some(&FIRST), true));
assert_eq!(mailbox.coalesced_total(), 1);
assert_eq!(mailbox.take(), (None, false));
}
#[test]
fn a_late_coalesced_mark_cannot_attach_to_the_next_error() {
static FIRST: StaticError = StaticError::new("first");
static NEXT: StaticError = StaticError::new("next");
let mailbox = StaticErrorMailbox::new();
mailbox.record(&FIRST);
// Reproduce the losing producer's observed pointer, then let the
// worker drain it and a different error arrive before that producer's
// bounded marking CAS.
let observed = mailbox.state.load(Ordering::Acquire);
assert_eq!(mailbox.take(), (Some(&FIRST), false));
mailbox.record(&NEXT);
mailbox.mark_coalesced(observed);
assert_eq!(mailbox.take(), (Some(&NEXT), false));
assert_eq!(mailbox.take(), (None, false));
}
}
+20 -16
View File
@@ -61,7 +61,7 @@ impl LogFlags {
pub const TRACE: Self = Self(1 << 0);
/// Text did not fit the fixed record buffer.
pub const TRUNCATED: Self = Self(1 << 1);
/// Adjacent equivalent records were coalesced.
/// Multiple ISR producer observations were folded into one mailbox event.
pub const COALESCED: Self = Self(1 << 2);
/// Raw MAVLink bitmask.
@@ -69,6 +69,11 @@ impl LogFlags {
self.0
}
/// Bits valid for an ordinary `KAPTAN_LOG` record.
pub const fn log_bits(self) -> u8 {
self.0 & (Self::TRACE.0 | Self::TRUNCATED.0)
}
/// Bits valid for an error-only record. Trace is a property of ordinary
/// `KAPTAN_LOG` diagnostics and is intentionally never emitted on
/// `KAPTAN_ERROR`.
@@ -122,8 +127,6 @@ pub struct LogRecord {
severity: Severity,
flags: LogFlags,
body: RecordBody,
/// Adjacent equivalent diagnostics retained as one record.
pub occurrences: u32,
}
impl LogRecord {
@@ -144,7 +147,6 @@ impl LogRecord {
severity,
flags: with_truncation(flags, message),
body: RecordBody::Log { message },
occurrences: 1,
}
}
@@ -158,6 +160,20 @@ impl LogRecord {
Self::error_with_severity(Severity::Error, flags, path, additional_information)
}
/// Creates one record from a typed derived error without choosing a sink.
pub fn from_error<E: ErrorReport>(flags: LogFlags, error: &E) -> Self {
Self::from_static_error(flags, error.static_error())
}
/// Creates one record from an ISR-safe static error without choosing a sink.
pub fn from_static_error(flags: LogFlags, error: &'static StaticError) -> Self {
Self::error(
flags,
ErrorPath::from_message(error.path()),
ErrorAdditionalInformation::from_message(error.additional_information()),
)
}
fn error_with_severity(
severity: Severity,
flags: LogFlags,
@@ -172,7 +188,6 @@ impl LogRecord {
path,
additional_information,
},
occurrences: 1,
}
}
@@ -228,17 +243,6 @@ impl LogRecord {
RecordBody::Error { .. } => DeliveryClass::Critical,
}
}
/// Tests whether two pending records can coalesce without hiding a new
/// payload, severity, or delivery class.
pub fn is_equivalent_to(&self, other: &Self) -> bool {
self.severity == other.severity && self.flags == other.flags && self.body == other.body
}
pub(crate) fn increment_occurrences(&mut self) {
self.occurrences = self.occurrences.saturating_add(1);
self.flags = self.flags.with(LogFlags::COALESCED);
}
}
fn with_truncation(mut flags: LogFlags, value: LogText) -> LogFlags {
+2 -2
View File
@@ -4,8 +4,8 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum ShutdownReason {
/// An operator initiated the controlled stop.
Operator = 1,
/// A user initiated the controlled stop.
User = 1,
/// Safety initiated the controlled stop.
Safety = 2,
}
+66 -16
View File
@@ -2,29 +2,35 @@
use core::fmt::{self, Write};
/// Maximum bytes retained in one transport-neutral diagnostic text component.
/// Maximum bytes retained in one ordinary log message.
pub const MAXIMUM_LOG_TEXT_BYTES: usize = 160;
/// Maximum bytes retained in one error identity path.
pub const MAXIMUM_ERROR_PATH_BYTES: usize = 96;
/// Maximum bytes retained in one error additional-information value.
pub const MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES: usize = 64;
/// Fixed UTF-8 diagnostic text with an explicit truncation marker.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LogText {
bytes: [u8; MAXIMUM_LOG_TEXT_BYTES],
pub struct BoundedText<const N: usize> {
bytes: [u8; N],
length: u8,
truncated: bool,
}
/// Transport-neutral error identity path. The MAVLink adapter applies its
/// separate 96-byte wire bound.
pub type ErrorPath = LogText;
/// Transport-neutral optional error detail. The MAVLink adapter applies its
/// separate 64-byte wire bound.
pub type ErrorAdditionalInformation = LogText;
/// Transport-neutral ordinary log message.
pub type LogText = BoundedText<MAXIMUM_LOG_TEXT_BYTES>;
/// Transport-neutral error identity path.
pub type ErrorPath = BoundedText<MAXIMUM_ERROR_PATH_BYTES>;
/// Transport-neutral optional error detail.
pub type ErrorAdditionalInformation = BoundedText<MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES>;
impl LogText {
impl<const N: usize> BoundedText<N> {
/// Creates empty fixed text storage.
pub const fn new() -> Self {
assert!(N > 0, "diagnostic text storage must be nonempty");
assert!(N <= u8::MAX as usize, "diagnostic text length must fit u8");
Self {
bytes: [0; MAXIMUM_LOG_TEXT_BYTES],
bytes: [0; N],
length: 0,
truncated: false,
}
@@ -46,10 +52,15 @@ impl LogText {
}
/// Raw bounded wire bytes.
pub const fn bytes(&self) -> &[u8; MAXIMUM_LOG_TEXT_BYTES] {
pub const fn bytes(&self) -> &[u8; N] {
&self.bytes
}
/// Maximum number of UTF-8 bytes retained by this text type.
pub const fn capacity(&self) -> usize {
N
}
/// Number of meaningful UTF-8 bytes.
pub const fn len(&self) -> u8 {
self.length
@@ -73,16 +84,16 @@ impl LogText {
}
}
impl Default for LogText {
impl<const N: usize> Default for BoundedText<N> {
fn default() -> Self {
Self::new()
}
}
impl fmt::Write for LogText {
impl<const N: usize> fmt::Write for BoundedText<N> {
fn write_str(&mut self, message: &str) -> fmt::Result {
let used = usize::from(self.length);
let remaining = MAXIMUM_LOG_TEXT_BYTES - used;
let remaining = N - used;
if message.len() <= remaining {
self.bytes[used..used + message.len()].copy_from_slice(message.as_bytes());
self.length = (used + message.len()) as u8;
@@ -104,7 +115,10 @@ impl fmt::Write for LogText {
#[cfg(test)]
mod tests {
use super::{LogText, MAXIMUM_LOG_TEXT_BYTES};
use super::{
ErrorAdditionalInformation, ErrorPath, LogText, MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES,
MAXIMUM_ERROR_PATH_BYTES, MAXIMUM_LOG_TEXT_BYTES,
};
#[test]
fn truncates_only_at_utf8_boundaries() {
@@ -114,4 +128,40 @@ mod tests {
assert_eq!(text.as_str().len(), MAXIMUM_LOG_TEXT_BYTES - 1);
assert!(core::str::from_utf8(&text.bytes()[..usize::from(text.len())]).is_ok());
}
#[test]
fn each_semantic_component_owns_only_its_exact_capacity() {
assert_eq!(LogText::new().capacity(), MAXIMUM_LOG_TEXT_BYTES);
assert_eq!(ErrorPath::new().capacity(), MAXIMUM_ERROR_PATH_BYTES);
assert_eq!(
ErrorAdditionalInformation::new().capacity(),
MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES
);
assert_eq!(core::mem::size_of::<LogText>(), MAXIMUM_LOG_TEXT_BYTES + 2);
assert_eq!(
core::mem::size_of::<ErrorPath>(),
MAXIMUM_ERROR_PATH_BYTES + 2
);
assert_eq!(
core::mem::size_of::<ErrorAdditionalInformation>(),
MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES + 2
);
}
#[test]
fn error_components_truncate_at_their_own_utf8_boundaries() {
let path = ErrorPath::from_message(&("p".repeat(MAXIMUM_ERROR_PATH_BYTES - 1) + "é"));
assert!(path.is_truncated());
assert_eq!(path.as_str().len(), MAXIMUM_ERROR_PATH_BYTES - 1);
let information = ErrorAdditionalInformation::from_message(
&("i".repeat(MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES - 1) + "é"),
);
assert!(information.is_truncated());
assert_eq!(
information.as_str().len(),
MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES - 1
);
}
}