From 98b409c85735272b7ed5b83c407e92a16b78cacd Mon Sep 17 00:00:00 2001 From: Joel DSouza Date: Sat, 4 Jul 2026 22:35:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(core):=20typed=20error=20domains=20?= =?UTF-8?q?=E2=80=94=20Tell/Ask=20split,=20PanicError,=20ActorStopReason?= =?UTF-8?q?=20(#113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild src/error.rs from the kameo reference oracle, diverging where the type system pays for itself. Split kameo's single SendError into two honest types: - TellError: fire-and-forget delivery failures (ActorNotAlive / MailboxFull); the undelivered message is always handed back (msg is total). - AskError: composes TellError via Deliver(..), plus the three reply-side failures a tell can never have (Timeout / Interrupted / Handler(E)). From bridges them so `?` works. Whether the message returns is encoded in the variant, not an Option. Retryability is a method (is_retryable / is_terminal), never a caller's guess (rule #3): only delivery backpressure is retryable — a Timeout is not (the message is already in the actor), and a Handler domain error (where nexus's Conflict lives) must never be re-driven as backpressure. Lifecycle side: ActorStopReason (Normal / Killed / Panicked / SupervisorRestart + is_normal) and PanicError, which holds the type-erased payload behind a plain Arc — no Mutex, since the ReplyError Send+Sync bound recovers Sync without a lock. PanicReason distinguishes a lifecycle-hook failure from a handler panic (the supervisor's restart-storm signal). downcast-rs + thiserror adopted (no manual Display). Deferred to producing cards: ActorStopReason::LinkDied / PeerDisconnected, PanicReason::OnLinkDied, TellError::SendTimeout, serde on the remote tier. 13 tests (TDD red→green) incl. the @bug conflict_is_domain_not_retryable probe and Display stability. Mutation: 0 survivors. nix flake check green. --- Cargo.lock | 2 + Cargo.toml | 5 + bombay-core/Cargo.toml | 2 + bombay-core/src/error.rs | 546 ++++++++++++++++++++++++++++++ bombay-core/src/lib.rs | 1 + docs/testing/coverage-baseline.md | 26 ++ 6 files changed, 582 insertions(+) create mode 100644 bombay-core/src/error.rs diff --git a/Cargo.lock b/Cargo.lock index 9af86a5..35e8668 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -477,9 +477,11 @@ dependencies = [ "async-channel", "criterion", "crossbeam-channel", + "downcast-rs 2.0.2", "flume", "proptest", "thingbuf", + "thiserror 2.0.18", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 8d5a82e..1614132 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,11 @@ flume = "0.12" cucumber = { version = "0.23", features = ["libtest"] } proptest = "1.11" rstest = "0.26" +# Typed error domains (card #113): thiserror for derived Display/Error (rule #3: +# no manual impls); downcast-rs to recover a concrete panic payload from the +# type-erased ReplyError trait object. +thiserror = "2.0" +downcast-rs = "2.0" # Tier-2 (PTY) console E2E test only (card #83): drive the compiled binary through a # pseudo-terminal and re-emulate the visible screen for grid assertions. portable-pty = "0.9" diff --git a/bombay-core/Cargo.toml b/bombay-core/Cargo.toml index 1dff3b2..831dd61 100644 --- a/bombay-core/Cargo.toml +++ b/bombay-core/Cargo.toml @@ -11,6 +11,8 @@ publish = false [dependencies] tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] } flume = { workspace = true } +thiserror = { workspace = true } +downcast-rs = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = [ diff --git a/bombay-core/src/error.rs b/bombay-core/src/error.rs new file mode 100644 index 0000000..4622d53 --- /dev/null +++ b/bombay-core/src/error.rs @@ -0,0 +1,546 @@ +//! Typed error domains for the local actor spine (card #113). +//! +//! One variant = one failure domain; retryability is a *method*, never a +//! caller's guess (CLAUDE rule #3). The send path is split into two honest +//! types instead of kameo's single `SendError`: +//! +//! * [`TellError`] — fire-and-forget *delivery* failures. The message never +//! reached the actor, so it is always handed back. +//! * [`AskError`] — a `tell` (which may fail as a [`TellError`]) followed by +//! awaiting a reply (which may fail three further ways). Composes +//! [`TellError`] via [`AskError::Deliver`], so an ask that fails to deliver +//! is *literally* a delivery failure — no duplicated variants. +//! +//! This split makes illegal states unrepresentable: a `tell` caller cannot +//! even name `Timeout`/`Handler`, and whether the message is returned is +//! encoded in the type rather than left to `Option`. + +use std::{fmt, sync::Arc}; + +use downcast_rs::{DowncastSync, impl_downcast}; + +/// A fire-and-forget delivery failure: the message never reached the actor. +/// +/// Both variants carry the undelivered message `M` back to the caller — there +/// is nothing to lose into the void, so [`TellError::msg`] is total. +#[derive(thiserror::Error, Debug)] +pub enum TellError { + /// The target actor is not alive (a stale slab key: never started, or + /// stopped). **Terminal** — retrying can only spin. + #[error("actor not alive")] + ActorNotAlive(M), + /// The actor's mailbox is full. **Retryable** — this is backpressure; + /// nothing was delivered, so re-sending the returned message is safe. + #[error("mailbox full")] + MailboxFull(M), +} + +impl TellError { + /// `true` for the single retry-safe variant, [`MailboxFull`](Self::MailboxFull). + #[must_use] + pub const fn is_retryable(&self) -> bool { + matches!(self, Self::MailboxFull(_)) + } + + /// `true` for the single terminal variant, [`ActorNotAlive`](Self::ActorNotAlive). + #[must_use] + pub const fn is_terminal(&self) -> bool { + matches!(self, Self::ActorNotAlive(_)) + } + + /// Re-types the carried message, preserving the variant. + pub fn map_msg(self, f: impl FnOnce(M) -> N) -> TellError { + match self { + Self::ActorNotAlive(m) => TellError::ActorNotAlive(f(m)), + Self::MailboxFull(m) => TellError::MailboxFull(f(m)), + } + } +} + +/// A request/reply failure: a delivery ([`TellError`]) followed by awaiting a +/// reply, which can fail three further ways a `tell` never can. +/// +/// `E` is the actor's own domain error — kept *composed and un-erased* (the +/// opposite of ractor's `Box`), so a nexus `Conflict` reaches the +/// caller typed and distinct from backpressure. Defaults to [`Infallible`] for +/// actors whose handlers cannot fail. +#[derive(thiserror::Error, Debug)] +pub enum AskError { + /// The delivery half failed exactly as a `tell` would; carries `M` back. + #[error(transparent)] + Deliver(TellError), + /// The message was delivered but no reply arrived in time. **Transient**, + /// but not retryable: the message is already in the actor. + #[error("reply timed out")] + Timeout, + /// The actor accepted the message, then died before replying (its reply + /// port was dropped). Distinct from `ActorNotAlive` (it *was* alive) and + /// `Timeout` (no deadline elapsed). + #[error("interrupted before reply")] + Interrupted, + /// The handler replied with its own domain error `E` (e.g. nexus + /// `Conflict`). Never retryable — a retry would corrupt single-writer. + #[error(transparent)] + Handler(E), +} + +impl AskError { + /// `true` only for delivery backpressure. A `Timeout` is deliberately *not* + /// retryable (the message is already in the actor), and a `Handler` domain + /// error must never be re-driven as backpressure (rule #3). + #[must_use] + pub const fn is_retryable(&self) -> bool { + matches!(self, Self::Deliver(inner) if inner.is_retryable()) + } + + /// `true` only when the underlying delivery failure is terminal. + #[must_use] + pub const fn is_terminal(&self) -> bool { + matches!(self, Self::Deliver(inner) if inner.is_terminal()) + } + + /// Recovers the undelivered message: `Some` only for a `Deliver` failure + /// (never enqueued); `None` for every reply-side failure (already in the + /// actor, so handing it back would duplicate it). + #[must_use] + pub fn msg(self) -> Option { + match self { + Self::Deliver(TellError::ActorNotAlive(m) | TellError::MailboxFull(m)) => Some(m), + Self::Timeout | Self::Interrupted | Self::Handler(_) => None, + } + } + + /// Recovers the handler's domain error: `Some` only for `Handler`. + #[must_use] + pub fn err(self) -> Option { + match self { + Self::Handler(e) => Some(e), + Self::Deliver(_) | Self::Timeout | Self::Interrupted => None, + } + } + + /// Re-types the carried message (delivery failures only), preserving the + /// variant; reply-side failures pass through untouched. + pub fn map_msg(self, f: impl FnOnce(M) -> N) -> AskError { + match self { + Self::Deliver(inner) => AskError::Deliver(inner.map_msg(f)), + Self::Timeout => AskError::Timeout, + Self::Interrupted => AskError::Interrupted, + Self::Handler(e) => AskError::Handler(e), + } + } + + /// Re-types the handler domain error (the `Handler` variant only), + /// preserving every other variant. + pub fn map_err(self, f: impl FnOnce(E) -> F) -> AskError { + match self { + Self::Deliver(inner) => AskError::Deliver(inner), + Self::Timeout => AskError::Timeout, + Self::Interrupted => AskError::Interrupted, + Self::Handler(e) => AskError::Handler(f(e)), + } + } +} + +impl From> for AskError { + fn from(err: TellError) -> Self { + Self::Deliver(err) + } +} + +/// The empty error type for actors whose handlers cannot fail. +/// +/// A local re-export placeholder until the message/reply cards settle the +/// canonical spot; `core::convert::Infallible` has no inhabitants, so an +/// `AskError` provably never carries a `Handler`. +pub use core::convert::Infallible; + +/// The bound on any value stored type-erased as a caught panic payload. +/// +/// `Send + Sync` (via [`DowncastSync`]) is what lets [`PanicError`] share the +/// payload behind a plain `Arc` — no `Mutex`, no lock on downcast. `Debug` is +/// for reporting; `'static` (via `DowncastSync`) enables the downcast. Every +/// sane error type satisfies this for free through the blanket impl. `Display` +/// and `serde` are deliberately *not* required here — arbitrary panic payloads +/// cannot guarantee them; the Zenoh tier adds serde behind its feature. +pub trait ReplyError: DowncastSync + fmt::Debug {} +impl ReplyError for T where T: fmt::Debug + Send + Sync + 'static {} +impl_downcast!(sync ReplyError); + +/// Which phase of an actor's life produced a panic. +/// +/// The distinction is load-bearing for supervision: restarting an actor that +/// panicked *during startup* just re-panics it (a crash loop), so a supervisor +/// treats a lifecycle-hook failure differently from a message-handler panic. +#[derive(thiserror::Error, Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PanicReason { + /// A message handler unwound during execution. + #[error("message handler")] + HandlerPanic, + /// The `on_start` lifecycle hook failed. + #[error("on_start hook")] + OnStart, + /// The `on_stop` lifecycle hook failed. + #[error("on_stop hook")] + OnStop, + /// The `on_panic` lifecycle hook itself failed. + #[error("on_panic hook")] + OnPanic, + // DEFERRED — OnLinkDied lands with the link graph / supervision (#120). +} + +impl PanicReason { + /// `true` if the panic occurred in a lifecycle hook rather than a message + /// handler — the "refuse to restart-storm" signal for a supervisor. + #[must_use] + pub const fn is_lifecycle_hook(self) -> bool { + !matches!(self, Self::HandlerPanic) + } +} + +/// A caught panic, turned from an un-handleable unwind into an inspectable +/// value so a supervisor can decide restart/escalate. +/// +/// The payload is stored behind a plain `Arc` — `Arc` so a +/// single death reason fans out to every watcher without cloning an +/// un-cloneable payload, and *no* `Mutex` because the [`ReplyError`] `Sync` +/// bound already makes the shared payload thread-safe. +#[derive(thiserror::Error, Clone, Debug)] +#[error("actor panicked ({reason}): {err:?}")] +pub struct PanicError { + err: Arc, + reason: PanicReason, +} + +impl PanicError { + /// Wraps an already-typed payload with the phase that produced it. + #[must_use] + pub fn new(err: Box, reason: PanicReason) -> Self { + Self { + err: Arc::from(err), + reason, + } + } + + /// The phase that panicked. + #[must_use] + pub const fn reason(&self) -> PanicReason { + self.reason + } + + /// Recovers a concrete payload by type, cloned. `None` on a type mismatch. + #[must_use] + pub fn downcast(&self) -> Option { + self.err.downcast_ref::().cloned() + } + + /// Calls `f` with the payload viewed as a `&str`, if it is one — the common + /// case, since most panics carry a `&str` or `String` message. + pub fn with_str(&self, f: impl FnOnce(&str) -> R) -> Option { + self.err + .downcast_ref::<&str>() + .copied() + .or_else(|| self.err.downcast_ref::().map(String::as_str)) + .map(f) + } + // DEFERRED — from_panic_any (Box → PanicError) lands with the + // run-loop that catches panics (#116); nothing produces one until then. +} + +/// Why an actor stopped. Exhaustive (no `#[non_exhaustive]`, rule #3) and +/// `Clone` because a death reason fans out to every watcher. +/// +/// Variant *production* is split across cards; the two variants that need +/// not-yet-built types are deferred: `LinkDied { id: ActorId, .. }` (#120/#121) +/// and `PeerDisconnected` (the Zenoh remote tier). +#[derive(thiserror::Error, Clone, Debug)] +pub enum ActorStopReason { + /// The actor finished its work and shut down cleanly. + #[error("stopped normally")] + Normal, + /// The actor was killed — a hard stop with no cleanup. + #[error("killed")] + Killed, + /// The actor's code panicked mid-execution. + #[error(transparent)] + Panicked(PanicError), + /// A supervisor is deliberately cycling the actor. + #[error("supervisor restart")] + SupervisorRestart, +} + +impl ActorStopReason { + /// `true` for an *expected* stop (leave it dead / it is being cycled), + /// `false` for an abnormal one (kill, panic). The one bit a supervisor + /// branches on. + #[must_use] + pub const fn is_normal(&self) -> bool { + matches!(self, Self::Normal | Self::SupervisorRestart) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Delivery failures classify by *retry safety*, not by name. `MailboxFull` + /// is backpressure — the message bounced, nothing was delivered, so a retry + /// is safe. `ActorNotAlive` is terminal — the target is gone; a retry loop + /// would spin forever. A blind retry loop must be able to tell them apart + /// from the type alone (CLAUDE rule #3). + #[test] + fn tell_error_classifies_retry_safety() { + let full: TellError = TellError::MailboxFull(1); + assert!(full.is_retryable(), "backpressure is retryable"); + assert!(!full.is_terminal(), "backpressure is not terminal"); + + let gone: TellError = TellError::ActorNotAlive(1); + assert!(!gone.is_retryable(), "a dead actor is never retryable"); + assert!(gone.is_terminal(), "a dead actor is terminal"); + } + + /// A stand-in domain error, e.g. the shape a nexus aggregate's own + /// `thiserror` enum takes (optimistic-concurrency `Conflict`, …). A proper + /// `Error` (so it can sit behind `#[error(transparent)]`) and `Clone` (so it + /// can be recovered from a type-erased [`PanicError`]). + #[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)] + #[error("optimistic-concurrency conflict")] + struct Conflict; + + /// The reply half adds three failures a `tell` can never have, and *only* + /// backpressure — reached via [`AskError::Deliver`] — is retryable. In + /// particular a `Timeout` is **not** retryable (the message is already in + /// the actor; a re-send would double-process), and a `Handler` domain + /// error (where a nexus `Conflict` lives) must never be retried as + /// backpressure or the single-writer guarantee is corrupted (rule #3). + #[test] + fn ask_error_classifies_retry_safety() { + let full: AskError = AskError::Deliver(TellError::MailboxFull(1)); + assert!(full.is_retryable(), "delivery backpressure stays retryable"); + assert!(!full.is_terminal()); + + let gone: AskError = AskError::Deliver(TellError::ActorNotAlive(1)); + assert!(!gone.is_retryable()); + assert!( + gone.is_terminal(), + "a dead actor is terminal through Deliver" + ); + + for reply_side in [ + AskError::::Timeout, + AskError::::Interrupted, + AskError::::Handler(Conflict), + ] { + assert!( + !reply_side.is_retryable(), + "reply-side failures never retry" + ); + assert!( + !reply_side.is_terminal(), + "reply-side failures are not terminal" + ); + } + } + + /// Whether the message comes back is encoded in the variant, not an + /// `Option` guess: delivery failures return `Some(M)` (never enqueued); + /// reply-side failures return `None` (already in the actor — handing it back + /// would duplicate it). The domain error is recoverable only from `Handler`. + #[test] + fn ask_error_recovers_message_and_error() { + assert_eq!( + AskError::::Deliver(TellError::MailboxFull(9)).msg(), + Some(9) + ); + assert_eq!( + AskError::::Deliver(TellError::ActorNotAlive(9)).msg(), + Some(9) + ); + assert_eq!(AskError::::Timeout.msg(), None); + assert_eq!(AskError::::Interrupted.msg(), None); + assert_eq!(AskError::::Handler(Conflict).msg(), None); + + assert_eq!( + AskError::::Handler(Conflict).err(), + Some(Conflict) + ); + assert_eq!(AskError::::Timeout.err(), None); + assert_eq!( + AskError::::Deliver(TellError::MailboxFull(9)).err(), + None + ); + } + + /// An ask *is* a tell then a wait, so a delivery failure converts into an + /// `AskError` with a bare `?` — no per-variant re-mapping. + #[test] + fn ask_error_composes_from_tell_error() { + fn deliver() -> Result<(), TellError> { + Err(TellError::MailboxFull(3)) + } + fn ask() -> Result<(), AskError> { + deliver()?; + Ok(()) + } + assert!(matches!( + ask(), + Err(AskError::Deliver(TellError::MailboxFull(3))) + )); + } + + /// `@bug` — a nexus optimistic-concurrency `Conflict` is a *domain* answer, + /// surfaced as `Handler(Conflict)`, and must classify as **not retryable**. + /// This test FAILS if `Conflict` is ever conflated with a retryable code: + /// a caller's retry loop would silently re-drive the conflict as + /// backpressure and corrupt the single-writer guarantee (rule #3). + #[test] + fn conflict_is_domain_not_retryable() { + let conflict: AskError = AskError::Handler(Conflict); + assert!( + !conflict.is_retryable(), + "a domain Conflict must never retry" + ); + assert!( + !conflict.is_terminal(), + "a Conflict is a live answer, not a dead actor" + ); + assert_eq!( + conflict.err(), + Some(Conflict), + "the typed error survives, un-erased" + ); + } + + /// The message payload can be re-typed on failure without collapsing the + /// variant — the reactivation layer (#20) uses this to re-wrap a returned + /// message. Reply-side failures have no message, so `map_msg` is a no-op there. + #[test] + fn map_msg_retypes_carried_message() { + let mapped = TellError::MailboxFull(7u8).map_msg(|m| u32::from(m) + 1); + assert!(matches!(mapped, TellError::MailboxFull(8u32))); + + let via_ask: AskError = AskError::Deliver(TellError::ActorNotAlive(7)); + assert_eq!(via_ask.map_msg(u32::from).msg(), Some(7u32)); + + // Reply-side: nothing to map, variant preserved. + let timeout: AskError = AskError::Timeout; + assert!(matches!(timeout.map_msg(u32::from), AskError::Timeout)); + } + + /// The domain error can be re-typed independently of the message — used at + /// the boundary where a caller adapts an aggregate's error into its own. + #[test] + fn map_err_retypes_handler_error() { + let mapped = AskError::::Handler(Conflict).map_err(|_| "conflict"); + assert_eq!(mapped.err(), Some("conflict")); + + let full: AskError = AskError::Deliver(TellError::MailboxFull(1)); + assert!(matches!( + full.map_err(|_| "x"), + AskError::Deliver(TellError::MailboxFull(1)) + )); + } + + /// A supervisor restarting a startup-panicking actor just re-panics it — + /// an instant crash loop. So `PanicReason` distinguishes a *lifecycle-hook* + /// failure (safe to refuse restart) from a plain message-handler panic. + #[test] + fn panic_reason_flags_lifecycle_hooks() { + assert!(PanicReason::OnStart.is_lifecycle_hook()); + assert!(PanicReason::OnStop.is_lifecycle_hook()); + assert!(PanicReason::OnPanic.is_lifecycle_hook()); + assert!( + !PanicReason::HandlerPanic.is_lifecycle_hook(), + "a handler panic is runtime, not lifecycle" + ); + } + + /// The one bit every supervisor branches on: was this an *expected* stop + /// (leave it dead) or an abnormal one? `SupervisorRestart` counts as normal + /// (the supervisor is deliberately cycling it); `Killed` does not (operator + /// pulled the plug) and neither does a panic. + #[test] + fn stop_reason_is_normal_classification() { + assert!(ActorStopReason::Normal.is_normal()); + assert!(ActorStopReason::SupervisorRestart.is_normal()); + assert!(!ActorStopReason::Killed.is_normal()); + assert!( + !ActorStopReason::Panicked(PanicError::new( + Box::new("boom"), + PanicReason::HandlerPanic + )) + .is_normal() + ); + } + + /// A panic payload is genuinely arbitrary, so it is stored type-erased and + /// recovered by trying known types. The overwhelmingly common panic — a + /// string — is recoverable as `&str`, and the phase is preserved verbatim. + #[test] + fn panic_error_recovers_str_and_reason() { + let panic = PanicError::new(Box::new(String::from("kaboom")), PanicReason::OnStart); + assert_eq!(panic.with_str(str::to_owned), Some(String::from("kaboom"))); + assert_eq!(panic.reason(), PanicReason::OnStart); + } + + /// A non-string payload is recovered by concrete type — this is what a + /// panic-probe test asserts on (rule #8: the specific value, not a Debug + /// substring). A mismatched type yields `None`. + #[test] + fn panic_error_downcasts_to_concrete_type() { + let panic = PanicError::new(Box::new(Conflict), PanicReason::HandlerPanic); + assert_eq!(panic.downcast::(), Some(Conflict)); + assert_eq!(panic.with_str(str::to_owned), None, "not a string payload"); + } + + /// A death reason fans out to every watcher, so `PanicError` is `Clone` — + /// and the clone shares the same `Arc`'d payload rather than duplicating it. + #[test] + fn panic_error_clone_shares_payload() { + let original = PanicError::new(Box::new(Conflict), PanicReason::OnPanic); + let cloned = original.clone(); + assert_eq!(cloned.downcast::(), Some(Conflict)); + assert_eq!(cloned.reason(), PanicReason::OnPanic); + // original still usable — clone did not consume it. + assert_eq!(original.reason(), PanicReason::OnPanic); + } + + /// Display strings are public surface (they show up in logs and `?` chains), + /// so pin them. `Deliver`/`Handler` are transparent — they delegate to the + /// inner error's own message rather than inventing a wrapper line. + #[test] + fn error_display_messages_are_stable() { + assert_eq!( + TellError::<()>::ActorNotAlive(()).to_string(), + "actor not alive" + ); + assert_eq!(TellError::<()>::MailboxFull(()).to_string(), "mailbox full"); + + assert_eq!( + AskError::<(), Conflict>::Timeout.to_string(), + "reply timed out" + ); + assert_eq!( + AskError::<(), Conflict>::Interrupted.to_string(), + "interrupted before reply" + ); + assert_eq!( + AskError::<(), Conflict>::Deliver(TellError::MailboxFull(())).to_string(), + "mailbox full", + "Deliver is transparent — shows the delivery reason, not a wrapper" + ); + assert_eq!( + AskError::<(), Conflict>::Handler(Conflict).to_string(), + "optimistic-concurrency conflict", + "Handler is transparent — the domain error's own message" + ); + + assert_eq!(ActorStopReason::Normal.to_string(), "stopped normally"); + assert_eq!(ActorStopReason::Killed.to_string(), "killed"); + assert_eq!( + ActorStopReason::SupervisorRestart.to_string(), + "supervisor restart" + ); + assert_eq!(PanicReason::OnStart.to_string(), "on_start hook"); + } +} diff --git a/bombay-core/src/lib.rs b/bombay-core/src/lib.rs index b10f883..6f4b480 100644 --- a/bombay-core/src/lib.rs +++ b/bombay-core/src/lib.rs @@ -7,4 +7,5 @@ //! Nothing here is public API yet — the spine is assembled part-by-part and the //! surface is settled once the whole core lands (#112–#121). +pub mod error; pub mod mailbox; diff --git a/docs/testing/coverage-baseline.md b/docs/testing/coverage-baseline.md index 5b4758f..91bb069 100644 --- a/docs/testing/coverage-baseline.md +++ b/docs/testing/coverage-baseline.md @@ -64,6 +64,32 @@ bombay-owned concurrency is the run-loop `select` over signals (#116) and the de push (#120) — that is where loom/shuttle land. The 8-thread linearizability test is the mailbox's concurrency coverage until then. +### `error` (#113) — done +Typed error domains, rebuilt to diverge from kameo where the type system pays off. +The single kameo `SendError` is **split into two honest types**: `TellError` +(fire-and-forget *delivery* failures — `ActorNotAlive`/`MailboxFull`, both hand the +message back) and `AskError`, which **composes** `TellError` via `Deliver(..)` and +adds the three reply-side failures a `tell` can never have (`Timeout`, `Interrupted`, +`Handler(E)`). So a `tell` caller cannot even *name* `Timeout`/`Handler`, and whether the +message is returned is encoded in the variant, not an `Option`. Retryability is a +**method** (`is_retryable`/`is_terminal`), never a caller's guess — only delivery +backpressure is retryable; a `Timeout` is not (the message is already in the actor) and a +`Handler` domain error (where a nexus `Conflict` lives) must never be re-driven as +backpressure (rule #3). `ActorStopReason` (`Normal`/`Killed`/`Panicked`/`SupervisorRestart` ++ `is_normal`) and `PanicError` complete the lifecycle side; `PanicError` holds the +type-erased payload behind a plain **`Arc`** (no `Mutex` — the `Send + Sync` +bound makes the shared payload thread-safe), recoverable by `downcast::()` / `with_str`. +`PanicReason` distinguishes a lifecycle-hook failure from a handler panic (the supervisor's +restart-storm signal). **`downcast-rs`** + **`thiserror`** adopted (rule #3: no manual +`Display`). 13 tests: retry/terminal classification (tell + ask), the `@bug` +`conflict_is_domain_not_retryable` probe, message/error recovery, `From` +composition, `map_msg`/`map_err`, `PanicReason`/`is_normal` classification, `PanicError` +downcast/`with_str`/clone-shares-`Arc`, and Display-message stability. **Mutation: 0 missed** +(`nix build .#mutants`; 17 caught, 12 unviable). No atomics/ordering here → loom/DST N/A. +**Deferred (tracked):** `ActorStopReason::LinkDied`/`PeerDisconnected`, `PanicReason::OnLinkDied`, +`TellError::SendTimeout`, and `serde` on `ReplyError`/`PanicError` land with their producing +cards (#120/#121, request builders #118, the Zenoh tier). + ## Baseline — 2026-06-29 (after #77) Workspace line coverage **60.85% (5686/9345)** — but that blends the SUT with untested crates