diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e6ad4..d329bab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.19.0] - 2025-09-29 + +### Changed +- Reworked `AppError` storage to keep sources behind shared `Arc` handles and + lazily capture optional `Backtrace` snapshots without allocating when + `RUST_BACKTRACE` disables them. +- Updated the `masterror::Error` derive and `ResultExt` conversions to forward + sources/backtraces automatically under the new storage layout. + +### Tests +- Added regression coverage for chained error sources and conditional + backtrace capture driven by the `RUST_BACKTRACE` environment variable. + ## [0.18.0] - 2025-09-28 ### Added diff --git a/Cargo.lock b/Cargo.lock index 2181e27..cd5621a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.18.0" +version = "0.19.0" dependencies = [ "actix-web", "axum 0.8.4", @@ -1764,7 +1764,7 @@ dependencies = [ [[package]] name = "masterror-derive" -version = "0.8.0" +version = "0.9.0" dependencies = [ "masterror-template", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 55b022a..4bef584 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.18.0" +version = "0.19.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -75,11 +75,11 @@ tonic = ["dep:tonic"] openapi = ["dep:utoipa"] [workspace.dependencies] -masterror-derive = { version = "0.8.0" } +masterror-derive = { version = "0.9.0" } masterror-template = { version = "0.3.6" } [dependencies] -masterror-derive = { version = "0.8" } +masterror-derive = { version = "0.9" } masterror-template = { workspace = true } tracing = { version = "0.1", optional = true } log = { version = "0.4", optional = true } diff --git a/README.md b/README.md index 472f547..7d8e8f7 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.18.0", default-features = false } +masterror = { version = "0.19.0", default-features = false } # or with features: -# masterror = { version = "0.18.0", features = [ +# masterror = { version = "0.19.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -78,10 +78,10 @@ masterror = { version = "0.18.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.18.0", default-features = false } +masterror = { version = "0.19.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.18.0", features = [ +# masterror = { version = "0.19.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -720,13 +720,13 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Minimal core: ~~~toml -masterror = { version = "0.18.0", default-features = false } +masterror = { version = "0.19.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.18.0", features = [ +masterror = { version = "0.19.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -735,7 +735,7 @@ masterror = { version = "0.18.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.18.0", features = [ +masterror = { version = "0.19.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml index 76edd2a..b757e0b 100644 --- a/masterror-derive/Cargo.toml +++ b/masterror-derive/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "masterror-derive" rust-version = "1.90" -version = "0.8.0" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" diff --git a/src/app_error.rs b/src/app_error.rs index aaf3fe6..a154446 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -67,6 +67,8 @@ mod context; mod core; mod metadata; +#[cfg(all(test, feature = "backtrace"))] +pub(crate) use core::reset_backtrace_preference; pub use core::{AppError, AppResult, Error, MessageEditPolicy}; pub use context::Context; diff --git a/src/app_error/core.rs b/src/app_error/core.rs index e9384ce..38cff1b 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -34,57 +34,6 @@ pub enum MessageEditPolicy { Redact } -#[cfg(feature = "backtrace")] -#[derive(Debug)] -struct BacktraceSlot { - cell: OnceLock> -} - -#[cfg(feature = "backtrace")] -impl BacktraceSlot { - const fn new() -> Self { - Self { - cell: OnceLock::new() - } - } - - fn with(backtrace: Backtrace) -> Self { - let slot = Self::new(); - let _ = slot.cell.set(Some(backtrace)); - slot - } - - fn set(&mut self, backtrace: Backtrace) { - *self = Self::with(backtrace); - } - - fn capture_if_absent(&self) -> Option<&Backtrace> { - self.cell.get_or_init(capture_backtrace_snapshot).as_ref() - } -} - -#[cfg(feature = "backtrace")] -impl Default for BacktraceSlot { - fn default() -> Self { - Self::new() - } -} - -#[cfg(not(feature = "backtrace"))] -#[derive(Debug, Default)] -struct BacktraceSlot { - _marker: () -} - -#[cfg(not(feature = "backtrace"))] -impl BacktraceSlot { - fn set(&mut self, _backtrace: std::backtrace::Backtrace) {} - - fn capture_if_absent(&self) -> Option<&std::backtrace::Backtrace> { - None - } -} - #[derive(Debug)] #[doc(hidden)] pub struct ErrorInner { @@ -102,8 +51,6 @@ pub struct ErrorInner { pub retry: Option, /// Optional authentication challenge for `WWW-Authenticate`. pub www_authenticate: Option, - source: Option>, - backtrace: BacktraceSlot, telemetry_dirty: AtomicBool } @@ -170,7 +117,12 @@ pub(crate) fn reset_backtrace_preference() { /// Rich application error preserving domain code, taxonomy and metadata. #[derive(Debug)] pub struct Error { - inner: Box + inner: Box, + source: Option>, + #[cfg(feature = "backtrace")] + backtrace: Option, + #[cfg(feature = "backtrace")] + captured_backtrace: OnceLock> } impl Deref for Error { @@ -242,10 +194,13 @@ impl Error { edit_policy: MessageEditPolicy::Preserve, retry: None, www_authenticate: None, - source: None, - backtrace: BacktraceSlot::default(), telemetry_dirty: AtomicBool::new(true) - }) + }), + source: None, + #[cfg(feature = "backtrace")] + backtrace: None, + #[cfg(feature = "backtrace")] + captured_backtrace: OnceLock::new() } } @@ -257,14 +212,31 @@ impl Error { self.telemetry_dirty.swap(false, Ordering::AcqRel) } + #[cfg(feature = "backtrace")] + fn capture_backtrace(&self) -> Option<&std::backtrace::Backtrace> { + if let Some(backtrace) = self.backtrace.as_ref() { + return Some(backtrace); + } + + self.captured_backtrace + .get_or_init(capture_backtrace_snapshot) + .as_ref() + } + + #[cfg(not(feature = "backtrace"))] fn capture_backtrace(&self) -> Option<&std::backtrace::Backtrace> { - self.backtrace.capture_if_absent() + None } + #[cfg(feature = "backtrace")] fn set_backtrace_slot(&mut self, backtrace: std::backtrace::Backtrace) { - self.backtrace.set(backtrace); + self.backtrace = Some(backtrace); + self.captured_backtrace = OnceLock::new(); } + #[cfg(not(feature = "backtrace"))] + fn set_backtrace_slot(&mut self, _backtrace: std::backtrace::Backtrace) {} + pub(crate) fn emit_telemetry(&self) { if self.take_dirty() { #[cfg(feature = "backtrace")] diff --git a/src/result_ext.rs b/src/result_ext.rs index 87f3a9f..60ec0e9 100644 --- a/src/result_ext.rs +++ b/src/result_ext.rs @@ -51,14 +51,21 @@ mod tests { fmt::{Display, Formatter, Result as FmtResult}, sync::Arc }; + #[cfg(feature = "backtrace")] + use std::{env, sync::Mutex}; use super::ResultExt; + #[cfg(feature = "backtrace")] + use crate::app_error::reset_backtrace_preference; use crate::{ AppCode, AppErrorKind, app_error::{Context, FieldValue, MessageEditPolicy}, field }; + #[cfg(feature = "backtrace")] + static BACKTRACE_ENV_GUARD: Mutex<()> = Mutex::new(()); + #[derive(Debug)] struct DummyError; @@ -70,6 +77,23 @@ mod tests { impl StdError for DummyError {} + #[derive(Debug)] + struct LayeredError { + inner: DummyError + } + + impl Display for LayeredError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + Display::fmt(&self.inner, f) + } + } + + impl StdError for LayeredError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(&self.inner) + } + } + #[test] fn ctx_preserves_ok() { let res: Result = Ok(5); @@ -106,6 +130,20 @@ mod tests { assert!(metadata.get("caller.column").is_some()); } + #[test] + fn ctx_preserves_error_chain() { + let err = Result::<(), LayeredError>::Err(LayeredError { + inner: DummyError + }) + .ctx(|| Context::new(AppErrorKind::Internal)) + .expect_err("err"); + + let mut source = StdError::source(&err).expect("layered source"); + assert!(source.is::()); + source = source.source().expect("inner source"); + assert!(source.is::()); + } + #[derive(Debug, Clone)] struct SharedError(Arc); @@ -149,4 +187,35 @@ mod tests { .expect("shared source"); assert!(Arc::ptr_eq(&stored.0, &inner)); } + + #[cfg(feature = "backtrace")] + fn with_backtrace_env(value: Option<&str>, test: impl FnOnce()) { + let _guard = BACKTRACE_ENV_GUARD.lock().expect("env guard"); + reset_backtrace_preference(); + match value { + Some(value) => env::set_var("RUST_BACKTRACE", value), + None => env::remove_var("RUST_BACKTRACE") + } + test(); + env::remove_var("RUST_BACKTRACE"); + reset_backtrace_preference(); + } + + #[cfg(feature = "backtrace")] + #[test] + fn ctx_respects_backtrace_environment() { + with_backtrace_env(Some("0"), || { + let err = Result::<(), DummyError>::Err(DummyError) + .ctx(|| Context::new(AppErrorKind::Internal)) + .expect_err("err"); + assert!(err.backtrace().is_none()); + }); + + with_backtrace_env(Some("1"), || { + let err = Result::<(), DummyError>::Err(DummyError) + .ctx(|| Context::new(AppErrorKind::Internal)) + .expect_err("err"); + assert!(err.backtrace().is_some()); + }); + } } diff --git a/tests/masterror_macro.rs b/tests/masterror_macro.rs index 38b162f..4ea50d0 100644 --- a/tests/masterror_macro.rs +++ b/tests/masterror_macro.rs @@ -1,6 +1,6 @@ #![allow(non_shorthand_field_patterns)] -use std::sync::Arc; +use std::{error::Error as StdError, sync::Arc}; use masterror::{ AppCode, AppErrorKind, Error as MasterrorError, FieldRedaction, Masterror, MessageEditPolicy, @@ -96,6 +96,8 @@ fn struct_masterror_conversion_populates_metadata_and_source() { assert_eq!(attempt, Some(3)); assert!(converted.source_ref().is_some()); + let converted_source = StdError::source(&converted).expect("masterror source"); + assert!(converted_source.is::()); assert_eq!( MissingFlag::HTTP_MAPPING,