|
Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
|
This document is the framework's authoritative reference for the error protocol. Every observable error surface MUST emit aria::Error values per the contract below. Together with lifecycle.md and api-style.md, this file forms the framework's three-pillar contract document family. Every contract item is numbered E-N for citation in code and commit messages.
What a "best-in-class C++ MVVM framework" requires of its error model:
| Kind | When it fires | Typical source |
|---|---|---|
| UserError | Caller passed something that obviously violates the API contract (null view-model, out-of-range, wrong type) | "Navigator", "Container" |
| Validation | Validator / FormValidator rule failed | "Validator", "FormValidator" |
| AsyncFailure | Async body threw a non-cancel, non-timeout exception | "AsyncCommand", "AsyncResource" |
| Cancellation | Coroutine exited via OperationCancelled (diagnostic surfaces only — last_error never carries this kind) | "AsyncCommand" |
| Timeout | with_timeout deadline hit (Race / Fail modes both map here) | "AsyncCommand", "with_timeout" |
| BindingFailure | Reserved for a future explicit binding error surface; current binding failures are reported through aria::CallbackFailure / callback_failure_sink (see E-30) | "BindingEngine", "<adapter>" |
| GraphCycle | Reactive graph reached kMaxFlushRounds, CircularDependencyError promotion | "Graph" |
| InvariantViolation | The framework's own contract was breached (stress / fuzz reports only) | "Validator", "Graph", ... |
ErrorKind numeric ordering is stable, never reordered, append-only. Reason: the enum crosses the ABI boundary (used as the value of Property<std::optional<Error>>); reordering would break dylib compatibility.
Severity::Warning is used only when kind == Validation: it denotes a soft advisory (should() rule). For every other kind the severity MUST be Severity::Error; framework code does not construct Warnings outside Validation.
UI rendering convention:
Error is copyable + EqualityComparable, so it can be the T of a Property<std::optional<Error>>.
exception_ptr is pointer identity, useless for value equality. The Property write-equality gate (L-21) compares kind / severity / message / source / key. Consequence: writing the same logical error twice does NOT re-notify observers.
Factory functions (Error::async_failure / cancellation / timeout / user_error / graph_cycle / validation / validation_warning) all take a source_tag parameter (with sensible defaults). Constructing an Error with an empty source and feeding it to an observation surface is forbidden; empty source is reserved for unit-test internals.
Error::from_exception(exception_ptr, source) recognises only standard library exceptions:
| Exception | Maps to |
|---|---|
| std::invalid_argument | UserError |
| std::out_of_range | UserError |
| Other std::exception | AsyncFailure |
| Unknown | AsyncFailure("unknown error") |
Every non-null input retains its original exception_ptr in inner.
Aria's own sentinel exceptions are NOT recognised inside from_exception — that would force error.hpp to back-include async/, reactive/ and break the layering. Each error surface (classify_async_exception / a future Graph::handle_cycle / ...) catches its sentinels first, calls a precise factory, and only lets the residue flow into from_exception.
AsyncCommand exposes:
Contract:
AsyncResource<T> exposes:
Contract:
ValidationState.errors: vector<Error> and .warnings: vector<Error>, each with kind = Validation and a populated key.
Contract:
The reactive graph's cycle detector still throws (CircularDependencyError's API stays stable, since it fires on construction- / set-time synchronous paths and the caller needs to know immediately).
But synchronous-path catchers (e.g. inside AsyncCommand's set chain) SHOULD wrap the exception into Error::graph_cycle(e.what(), current_exception()) and write it to the surface. classify_async_exception does NOT recognise CircularDependencyError today — that is a current gap, but since cycles are synchronous graph errors they should not normally arise inside an async body. The P0-ε fuzzer MUST verify this boundary.
Navigator::push(nullptr) still throws std::invalid_argument.
Rationale: Navigator is invoked synchronously by user code; a parameter error must be reported to the caller immediately or it gets deferred until the first stack-top access. throw is the elegant "never silent" form for this scenario.
If the caller invokes Navigator from inside an AsyncCommand body without catching it, classify_async_exception (per E-13) maps it to UserError via Error::from_exception — the error is still observable.
Container::resolve<I>() throws std::runtime_error when I was never registered. Semantics are identical to E-24: resolution happens synchronously in user code, so a missing registration is reported to the caller immediately rather than deferred.
There is no std::invalid_argument path — the container accepts no user-supplied values it could reject, only type keys. (std::any_cast inside resolve can in principle raise std::bad_any_cast if the same type_index is registered from two DSOs with incompatible types, but that is a build-configuration fault rather than a documented API outcome.)
BindingEngine does NOT expose a Property-shaped error observation surface. Reasons:
ErrorKind::BindingFailure is reserved: if we ever introduce an "explicit binding error surface" (e.g. a typed converter failing on VM→View), it activates then. No code in the current release emits ErrorKind::BindingFailure. Synchronous callback / converter / view-model boundary failures instead route through aria::report_callback_failure(...) and the host-installed callback_failure_sink; adapter setters remain expected to be idempotent and non-throwing.
aria::runtime::Logger::log is the framework's lowest-level observability primitive. It must be callable from any call site, including framework-internal noexcept boundaries (worker / drain / pump / abi trampoline). The contract:
| Scenario | Path | Choice |
|---|---|---|
| Construction-time argument validation failure | Sync | throw std::invalid_argument |
| Property::set triggered cycle | Sync | throw CircularDependencyError |
| AsyncCommand body throws | Async (worker, then back to ui) | set last_error and do NOT rethrow to the user (run_to_result_ folds the exception into AsyncCommandResult::{Cancelled, Failed}; execute() reports through error_sink_; co_execute() lets the caller branch on r.failed()) |
| AsyncResource fetch failure | as above | set error |
| Validator rule failure | Sync inside the graph | set state.errors |
| Cross-field rule failure | Sync inside the graph | set first_error_full |
Core principle:
| # | Anti-pattern | Consequence | Correct approach |
|---|---|---|---|
| AE1 | last_error.set(Error{kind=Cancellation, ...}) | UI displays "user cancellation" as an error | Cancellation does NOT go on the error surface; classify_async_exception already handles this correctly (folds into AsyncCommandResult::Cancelled, doesn't write last_error) |
| AE2 | Adapter setter throws std::runtime_error | Exception is swallowed by the dispatcher, UI fails silently | Don't throw from a setter; setters MUST be idempotent and non-throwing |
| AE3 | Constructing an Error with empty source and feeding it to a surface | The router can't tell which subsystem this came from | Every factory takes source_tag — the caller MUST provide it |
| AE4 | last_error.get() == "kaboom" to discriminate errors | String compare is fragile; messages may be localised | last_error.get()->kind == ErrorKind::AsyncFailure instead |
| AE5 | Letting OperationCancelled go through Error::from_exception | Becomes AsyncFailure("operation cancelled") on the observation surface | Catch OperationCancelled first in the catch chain and rethrow before from_exception sees it |
| AE6 | Navigator::push(nullptr) setting a Property instead of throwing | Caller proceeds; problem hidden | Synchronous paths MUST throw |
The error-model invariants below are pinned by fuzzers in modules/core/fuzz/, all built into the aria_fuzz binary and run by the fuzz_tests ctest target:
| Invariant | fuzzer | source |
|---|---|---|
| AsyncCommand cancellation never surfaces on the error face (E-20 clause 2) | fuzz: E-20 cancellation never surfaces on AsyncCommand's error face | fuzz_async_command_cancellation_no_error.cpp |
| Repeated set of the same Error does not notify observers (E-11 + L-21) | E-11 fuzz: equal Errors do not re-notify, unequal ones always do | fuzz_error_property_equality_gate.cpp |
| Validator errors' key.field_path always equals the Validator's path (E-22 clause 1) | fuzz: Validator field_path is an invariant of the validator | fuzz_validator_field_path.cpp |
| Error::from_exception mapping is stable across std exception types (E-13) | E-13 fuzz: from_exception mapping is stable across std exception types | fuzz_error_from_exception_table.cpp |
Notes on what these actually pin, since the shape is not obvious from the invariant statement alone:
Iteration count defaults to 50k per fuzzer; set ARIA_FUZZ_ITERS=1000000 (optionally with ARIA_FUZZ_SEED) for nightly / pre-release runs.
Every error-model protocol change MUST flow as: doc change → code change → test change. Any new ErrorKind MUST first be registered in the E-1 table here.