feat(errors): add stable error propagation
This commit is contained in:
1 parent
97acb2a2d7
commit
ba9c97ed3b
34 files changed
+1007
No files matched your search
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "kaptan-err-propagate"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
kaptan-err-propagate-macros = { path = "../kaptan-err-propagate-macros" }
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Fixed, allocation-free typed-error ingress.
|
||||
|
||||
use core::{
|
||||
cell::UnsafeCell,
|
||||
mem::MaybeUninit,
|
||||
sync::atomic::{AtomicU8, Ordering},
|
||||
};
|
||||
|
||||
use crate::{ErrorId, ErrorRecord};
|
||||
|
||||
static GLOBAL_INGRESS: ErrorIngress = ErrorIngress::new();
|
||||
|
||||
const EMPTY: u8 = 0;
|
||||
const WRITING: u8 = 1;
|
||||
const READY: u8 = 2;
|
||||
const READING: u8 = 3;
|
||||
|
||||
struct ErrorIngress {
|
||||
state: AtomicU8,
|
||||
pending: UnsafeCell<MaybeUninit<ErrorRecord>>,
|
||||
}
|
||||
|
||||
// SAFETY: `state` gives exactly one producer or consumer exclusive access to
|
||||
// `pending`; neither side waits for a preempted owner.
|
||||
unsafe impl Sync for ErrorIngress {}
|
||||
|
||||
impl ErrorIngress {
|
||||
const fn new() -> Self {
|
||||
Self {
|
||||
state: AtomicU8::new(EMPTY),
|
||||
pending: UnsafeCell::new(MaybeUninit::uninit()),
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(&self, id: ErrorId, raised_at: &'static str) {
|
||||
if self
|
||||
.state
|
||||
.compare_exchange(EMPTY, WRITING, Ordering::Acquire, Ordering::Relaxed)
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
// SAFETY: the successful state transition grants this producer sole
|
||||
// access until it publishes READY with release ordering.
|
||||
unsafe {
|
||||
(*self.pending.get()).write(ErrorRecord::new(id, raised_at));
|
||||
}
|
||||
self.state.store(READY, Ordering::Release);
|
||||
}
|
||||
|
||||
fn take(&self) -> Option<ErrorRecord> {
|
||||
if self
|
||||
.state
|
||||
.compare_exchange(READY, READING, Ordering::Acquire, Ordering::Relaxed)
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// SAFETY: READY proves that the sole producer initialized the record;
|
||||
// READING grants this consumer exclusive access until EMPTY.
|
||||
let record = unsafe { (*self.pending.get()).assume_init_read() };
|
||||
self.state.store(EMPTY, Ordering::Release);
|
||||
Some(record)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn publish(id: ErrorId, raised_at: &'static str) {
|
||||
GLOBAL_INGRESS.publish(id, raised_at);
|
||||
}
|
||||
|
||||
/// Takes the pending typed firmware error, if one exists.
|
||||
pub fn take() -> Option<ErrorRecord> {
|
||||
GLOBAL_INGRESS.take()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ErrorIngress;
|
||||
use crate::ErrorId;
|
||||
|
||||
#[test]
|
||||
fn retains_the_first_id_and_location_until_consumed() {
|
||||
let ingress = ErrorIngress::new();
|
||||
ingress.publish(ErrorId::new(65402), "first.rs:10");
|
||||
ingress.publish(ErrorId::new(65403), "second.rs:20");
|
||||
|
||||
let first = ingress.take().expect("first error retained");
|
||||
assert_eq!(first.id(), 65402);
|
||||
assert_eq!(first.raised_at(), "first.rs:10");
|
||||
assert!(ingress.take().is_none());
|
||||
|
||||
ingress.publish(ErrorId::new(65403), "second.rs:20");
|
||||
let second = ingress.take().expect("slot reusable after consumption");
|
||||
assert_eq!(second.id(), 65403);
|
||||
assert_eq!(second.raised_at(), "second.rs:20");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Bounded typed firmware errors and function-boundary propagation.
|
||||
//!
|
||||
//! This crate owns error identity and ingress. It contains no logging levels,
|
||||
//! filtering, MAVLink, board, filesystem, or clock dependency.
|
||||
//!
|
||||
//! `#[propagate]` reports at each annotated boundary. Annotate the terminal
|
||||
//! boundary where an error enters diagnostics; nested annotated callers would
|
||||
//! attempt to report the same propagation again. The bounded ingress retaining
|
||||
//! only one pending error is backpressure, not semantic duplicate suppression.
|
||||
|
||||
#![no_std]
|
||||
|
||||
#[cfg(test)]
|
||||
extern crate std;
|
||||
|
||||
mod ingress;
|
||||
mod record;
|
||||
mod sink;
|
||||
|
||||
pub use ingress::take;
|
||||
pub use kaptan_err_propagate_macros::{KaptanError, propagate};
|
||||
pub use record::{ErrorId, ErrorRecord, ErrorReport};
|
||||
pub use sink::ErrorSink;
|
||||
|
||||
/// Implementation details used only by macro expansions.
|
||||
#[doc(hidden)]
|
||||
pub mod __private {
|
||||
use crate::ErrorReport;
|
||||
|
||||
/// Reports an error only when it is the final result leaving an annotated
|
||||
/// function. Borrowing preserves the exact result returned to the caller.
|
||||
#[inline]
|
||||
pub fn report_result<T, E: ErrorReport>(
|
||||
result: &core::result::Result<T, E>,
|
||||
raised_at: &'static str,
|
||||
) {
|
||||
if let Err(error) = result {
|
||||
crate::ingress::publish(error.error_id(), raised_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Stable error IDs and bounded pending records.
|
||||
|
||||
/// Stable nonzero identity for one typed firmware error variant.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[repr(transparent)]
|
||||
pub struct ErrorId(u16);
|
||||
|
||||
impl ErrorId {
|
||||
/// Creates one explicitly assigned error identity.
|
||||
pub const fn new(id: u16) -> Self {
|
||||
assert!(id != 0, "error ID zero is reserved");
|
||||
Self(id)
|
||||
}
|
||||
|
||||
/// Returns the compact wire representation.
|
||||
pub const fn get(self) -> u16 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Implemented by [`crate::KaptanError`] derives.
|
||||
///
|
||||
/// Obtaining an ID never publishes, allocates, or changes safety state.
|
||||
pub trait ErrorReport {
|
||||
/// Returns the stable ID assigned to this error variant.
|
||||
fn error_id(&self) -> ErrorId;
|
||||
}
|
||||
|
||||
impl ErrorReport for ErrorId {
|
||||
fn error_id(&self) -> ErrorId {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
/// One reported typed firmware error and its reporting boundary.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ErrorRecord {
|
||||
id: ErrorId,
|
||||
raised_at: &'static str,
|
||||
}
|
||||
|
||||
impl ErrorRecord {
|
||||
pub(crate) const fn new(id: ErrorId, raised_at: &'static str) -> Self {
|
||||
Self { id, raised_at }
|
||||
}
|
||||
|
||||
/// Stable machine-readable identity for the error type and variant.
|
||||
pub const fn id(self) -> u16 {
|
||||
self.id.get()
|
||||
}
|
||||
|
||||
/// Compile-time `file:line` of the propagation boundary that reported it.
|
||||
pub const fn raised_at(self) -> &'static str {
|
||||
self.raised_at
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ErrorId, ErrorRecord};
|
||||
|
||||
#[test]
|
||||
fn record_keeps_only_the_explicit_id_and_reporting_boundary() {
|
||||
let record = ErrorRecord::new(ErrorId::new(65004), "module.rs:42");
|
||||
assert_eq!(record.id(), 65004);
|
||||
assert_eq!(record.raised_at(), "module.rs:42");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! One-slot retry ownership for a typed-error sink.
|
||||
|
||||
use crate::ErrorRecord;
|
||||
|
||||
/// Retains one typed error until a lower sink accepts it.
|
||||
///
|
||||
/// Typed errors are never filtered. A composition may give them priority over
|
||||
/// best-effort logs without coupling the producer to its transport.
|
||||
pub struct ErrorSink {
|
||||
pending: Option<ErrorRecord>,
|
||||
}
|
||||
|
||||
impl ErrorSink {
|
||||
/// Creates an empty sink.
|
||||
pub const fn new() -> Self {
|
||||
Self { pending: None }
|
||||
}
|
||||
|
||||
/// Whether this sink can accept a record from composition-level fanout.
|
||||
pub const fn is_empty(&self) -> bool {
|
||||
self.pending.is_none()
|
||||
}
|
||||
|
||||
/// Retains one record, returning it unchanged when this sink is occupied.
|
||||
pub fn publish_record(&mut self, record: ErrorRecord) -> Result<(), ErrorRecord> {
|
||||
if self.pending.is_some() {
|
||||
return Err(record);
|
||||
}
|
||||
self.pending = Some(record);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attempts the pending record and clears it only when `submit` accepts it.
|
||||
///
|
||||
/// Returns whether a record was attempted, independently of admission.
|
||||
pub fn submit_next(&mut self, submit: impl FnOnce(&ErrorRecord) -> bool) -> bool {
|
||||
let Some(record) = self.pending else {
|
||||
return false;
|
||||
};
|
||||
if submit(&record) {
|
||||
self.pending = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ErrorSink {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ErrorSink;
|
||||
use crate::{ErrorId, ErrorRecord};
|
||||
|
||||
#[test]
|
||||
fn retains_one_error_until_the_sink_accepts_it() {
|
||||
while crate::take().is_some() {}
|
||||
crate::ingress::publish(ErrorId::new(65401), "test.rs:10");
|
||||
|
||||
let mut sink = ErrorSink::new();
|
||||
let record = crate::take().expect("published error");
|
||||
sink.publish_record(record).unwrap();
|
||||
assert!(!sink.is_empty());
|
||||
assert!(sink.submit_next(|record| {
|
||||
assert_eq!(*record, ErrorRecord::new(ErrorId::new(65401), "test.rs:10"));
|
||||
false
|
||||
}));
|
||||
assert!(sink.submit_next(|record| {
|
||||
assert_eq!(record.id(), 65401);
|
||||
assert_eq!(record.raised_at(), "test.rs:10");
|
||||
true
|
||||
}));
|
||||
assert!(sink.is_empty());
|
||||
assert!(!sink.submit_next(|_| true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use kaptan_err_propagate::{ErrorId, ErrorReport, KaptanError};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||
enum TestError {
|
||||
#[kaptan_error(id = 65001)]
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||
enum OuterError {
|
||||
#[kaptan_error(id = 65002)]
|
||||
Converted(TestError),
|
||||
}
|
||||
|
||||
impl From<TestError> for OuterError {
|
||||
fn from(error: TestError) -> Self {
|
||||
Self::Converted(error)
|
||||
}
|
||||
}
|
||||
|
||||
fn fail() -> Result<(), TestError> {
|
||||
Err(TestError::Failed)
|
||||
}
|
||||
|
||||
fn middle() -> Result<(), TestError> {
|
||||
fail()
|
||||
}
|
||||
|
||||
#[kaptan_err_propagate::propagate]
|
||||
fn succeeds() -> Result<u8, TestError> {
|
||||
Ok(7)
|
||||
}
|
||||
|
||||
#[kaptan_err_propagate::propagate]
|
||||
fn annotated_outer() -> Result<(), TestError> {
|
||||
middle()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[kaptan_err_propagate::propagate]
|
||||
fn annotated_inner() -> Result<(), TestError> {
|
||||
fail()
|
||||
}
|
||||
|
||||
fn unannotated_outer() -> Result<(), TestError> {
|
||||
annotated_inner()
|
||||
}
|
||||
|
||||
#[kaptan_err_propagate::propagate]
|
||||
fn handles_inner_error() -> Result<(), TestError> {
|
||||
let _handled = fail().is_err();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[kaptan_err_propagate::propagate]
|
||||
fn returns_explicit_error(fail_at_boundary: bool) -> Result<(), TestError> {
|
||||
if fail_at_boundary {
|
||||
return Err(TestError::Failed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
type AliasedResult<T> = core::result::Result<T, TestError>;
|
||||
|
||||
#[kaptan_err_propagate::propagate]
|
||||
fn returns_alias() -> AliasedResult<()> {
|
||||
fail()
|
||||
}
|
||||
|
||||
#[kaptan_err_propagate::propagate]
|
||||
fn reports_final_converted_error() -> Result<(), OuterError> {
|
||||
fail()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[kaptan_err_propagate::propagate]
|
||||
fn reports_error_id() -> Result<(), ErrorId> {
|
||||
Err(ErrorId::new(65003))
|
||||
}
|
||||
|
||||
#[kaptan_err_propagate::propagate]
|
||||
async fn async_boundary() -> Result<(), TestError> {
|
||||
fail()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Service;
|
||||
|
||||
impl Service {
|
||||
#[kaptan_err_propagate::propagate]
|
||||
fn method(&self, fail_at_boundary: bool) -> Result<(), TestError> {
|
||||
if fail_at_boundary {
|
||||
fail()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn take_record() -> kaptan_err_propagate::ErrorRecord {
|
||||
kaptan_err_propagate::take().expect("escaping error must be reported")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_only_final_errors_leaving_annotated_boundaries() {
|
||||
while kaptan_err_propagate::take().is_some() {}
|
||||
|
||||
assert_eq!(succeeds(), Ok(7));
|
||||
assert!(kaptan_err_propagate::take().is_none());
|
||||
|
||||
assert_eq!(handles_inner_error(), Ok(()));
|
||||
assert!(kaptan_err_propagate::take().is_none());
|
||||
|
||||
assert_eq!(annotated_outer(), Err(TestError::Failed));
|
||||
assert_eq!(take_record().id(), TestError::Failed.error_id().get());
|
||||
|
||||
assert_eq!(unannotated_outer(), Err(TestError::Failed));
|
||||
assert_eq!(take_record().id(), TestError::Failed.error_id().get());
|
||||
|
||||
assert_eq!(returns_explicit_error(true), Err(TestError::Failed));
|
||||
assert_eq!(take_record().id(), TestError::Failed.error_id().get());
|
||||
|
||||
assert_eq!(returns_alias(), Err(TestError::Failed));
|
||||
assert_eq!(take_record().id(), TestError::Failed.error_id().get());
|
||||
|
||||
assert_eq!(
|
||||
reports_final_converted_error(),
|
||||
Err(OuterError::Converted(TestError::Failed))
|
||||
);
|
||||
assert_eq!(
|
||||
take_record().id(),
|
||||
OuterError::Converted(TestError::Failed).error_id().get()
|
||||
);
|
||||
|
||||
assert_eq!(reports_error_id(), Err(ErrorId::new(65003)));
|
||||
assert_eq!(take_record().id(), 65003);
|
||||
|
||||
assert_eq!(Service.method(false), Ok(()));
|
||||
assert!(kaptan_err_propagate::take().is_none());
|
||||
|
||||
let mut future = core::pin::pin!(async_boundary());
|
||||
let mut context = core::task::Context::from_waker(core::task::Waker::noop());
|
||||
assert_eq!(
|
||||
core::future::Future::poll(future.as_mut(), &mut context),
|
||||
core::task::Poll::Ready(Err(TestError::Failed))
|
||||
);
|
||||
let record = take_record();
|
||||
assert_eq!(record.id(), TestError::Failed.error_id().get());
|
||||
assert!(record.raised_at().contains("propagate.rs:"));
|
||||
}
|
||||
Reference in new issue
Block a user