feat(errors): add stable error propagation

This commit is contained in:
Onur Özkan committed 2026-08-23 00:40:17 +03:00
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:"));
}