|
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 unified diagnostics protocol. Every observable subsystem MUST emit aria::TraceEvent per the contract below. Together with lifecycle.md, api-style.md and error-model.md, this file forms the framework's four-pillar contract document family. Every contract item is numbered D-N for citation in code and commit messages.
What a "best-in-class C++ MVVM framework" requires of its diagnostic protocol:
| Category | When it fires | Primary publishers |
|---|---|---|
| Reactive | Graph flush / Pull / Recomputed / SkipClean / Round boundaries | Graph::flush |
| Async | AsyncCommand / AsyncResource lifecycle, async race arbitration | classify_async_exception / Invocation ctor/dtor / AsyncResource fetch / with_timeout / when_any / when_all |
| Binding | BindingEngine VM↔View dispatch | BindingEngine::dispatch_to_view_ / view.on_destroy callback |
| Command | Synchronous Command<Args...> and Command<> execution | Command::execute / notify_can_execute_changed |
| Validation | Validator rule evaluation and pending transitions | Validator::run_ / begin_pending / finish_pending_ |
| List | ObservableList structural mutations | ObservableList::emit_ |
Numeric ordering is stable — never reordered, append-only.
TracePayload = variant<Reactive, Async, Binding, Command, Validation, List>, aligned with the TraceCategory order. std::get_if<X>(&ev.payload) is the standard entry point for consumers wanting strongly-typed field access.
Adding a field to trace::Reactive / trace::Async / etc. is forward compatible — old consumers ignore unknown fields. Removing a field is breaking and MUST be called out in the CHANGELOG.
When the event represents "something failed", the error field MUST be a meaningful aria::Error:
Successful events leave error as nullopt; consumers use that to distinguish.
TraceSink has no rich API: a sink can be invoked, never queried. This keeps the diagnostic protocol from leaking into the business protocol.
Sink registration (install_trace_sink / clear_trace_sink / ScopedTraceSink) serialises through a global mutex. Publishing (publish_trace) takes a single lock_guard-scoped copy of the sink's shared_ptr, then invokes the sink outside the lock.
Implications:
publish_trace / publish_trace_unchecked wrap the call in a shared runtime callback boundary. Sink exceptions are reported through report_callback_failure; they do not escape into the business path. Nested traces produced by the sink on the same thread are suppressed to prevent unbounded recursion.
Tests SHOULD use ScopedTraceSink: it installs on construction and restores the previous state on destruction (which may be no sink or an outer scoped sink). Scopes may nest on one thread. Because the sink is process-wide, independent parallel tests must coordinate installation; scoped restoration does not provide per-test isolation.
Exact costs (per the call-site gating convention):
| Path | Real cost |
|---|---|
| Fast path (no sink) | One atomic presence load and branch at has_trace_sink(); no mutex, shared ownership increment, or payload construction. |
| Slow path (sink present) | One atomic presence check at the call site, then one owning snapshot under the sink mutex inside publish_trace_unchecked; invocation occurs outside the lock. |
The two publish entry points are deliberately split (D-1 implementation detail):
Every subsystem guards publishes with if (has_trace_sink()) { ... publish_trace_unchecked(...); } — mandatory whenever payload construction is non-trivial.
Graph::flush emits one trace::Reactive per phase boundary:
| phase | When | node_name | round | changed |
|---|---|---|---|---|
| FlushBegin | start of flush | empty | 0 | false |
| RoundBegin | start of each round | empty | 1..N | false |
| Pull | before pulling each dirty node | node debug name | current round | false |
| SkipClean | already-Clean node skipped | node debug name | current round | false |
| Recomputed | after recompute | node debug name | current round | whether the value actually changed |
| RoundEnd | end of each round | empty | current round | false |
| FlushEnd | end of flush | empty | total rounds | false |
Note: the reactive subsystem also keeps the legacy GraphInspector::install_flush_tracer protocol (FlushTracer + FlushEvent). The two coexist — the former is for "I only care about reactive internals" fine-grained debugging, the latter is the unified diagnostic. Both are independently controllable.
AsyncCommand::Invocation fires execute_start / execute_finish in its ctor / dtor, with generation set to the inflight count at that moment. classify_async_exception produces one event per branch:
| op | Trigger | error field |
|---|---|---|
| cancelled | OperationCancelled | Error::cancellation(...) |
| timeout | TimeoutError | Error::timeout(...) |
| failure | other | Error::from_exception(...) |
AsyncResource exposes finer-grained events: cache_hit / dedupe / fetch_start / fetch_finish / stale_drop / cancelled / timeout / failure. generation is that fetch's gen counter.
with_timeout, when_any, when_any_cancellable and when_all already arbitrate a race internally; these events expose who won, who lost, and why, so async debugging has the same observability as the reactive and binding flows.
source identifies the combinator, not the user's work:
| source | Published by |
|---|---|
| with_timeout | both the cooperative (OnTimeout::Cancel) and fail-fast (OnTimeout::Fail) paths |
| when_any | when_any(std::vector<Task<T>>) |
| when_any_cancellable | when_any_cancellable(factories) |
| when_all | when_all(tasks...) |
| op | When | generation | error field |
|---|---|---|---|
| race_start | Arbitration armed: participants spawned, deadline (if any) scheduled. | participant count (when_all / when_any); 0 for with_timeout, which always has exactly one inner task plus a deadline | — |
| race_won | A participant claimed the race and published a result. | winner index (0-based) for the when_any family; 0 for with_timeout, where the only participant is the inner task | — |
| race_timeout | The deadline claimed the race before the inner task did. | 0 | Error::timeout("with_timeout") |
| race_loser_cancel | Losers were asked to cancel after a winner emerged. Emitted once per race, not once per loser. | number of losers signalled | — |
| race_parent_cancel | An engaged parent token cancelled the race; beats a concurrent deadline. | 0 | Error::cancellation(...) |
| race_end | Arbitration finished and the awaiting coroutine is about to resume. | 0 | — |
Ordering guarantees a consumer may rely on:
Why arbitration events and not per-participant events: a race with N participants would otherwise emit O(N) events on a hot path for a question ("who won?") that has exactly one answer. The publish sites sit inside the already-taken CAS branch, so no event is emitted on the uncontended fast path.
The events are published from whichever thread won the race — a timer thread for race_timeout, a worker thread for race_won. Sinks must already assume this (AD5).
BindingEngine::dispatch_to_view_ produces one of:
| op | When |
|---|---|
| vm_to_view | The user callback actually ran. |
| view_destroyed_drop | The alive_token expired, posted callback was dropped. |
view.on_destroy fires one view_destroyed.
Command::execute and Command<>::execute:
| op | When |
|---|---|
| execute | Predicate passed; action is about to run. |
| rejected_can_execute | Predicate rejected. |
| can_execute_changed | notify_can_execute_changed was called. |
Validator::run_ emits rule_pass or rule_fail per rule (warnings do warning_pass / warning_fail). key is (field_path, rule_id); *_fail also includes message. begin_pending / finish_pending_ each fire once.
ObservableList::emit_ mirrors every structural change broadcast to a trace::List:
| op | Meaning | index | from_index | size_after |
|---|---|---|---|---|
| Insert | New element added | insertion index | 0 | size after insert |
| Remove | Element removed | removal index | 0 | size after remove |
| Replace | Element replaced | index | 0 | unchanged |
| ItemChanged | T's own on_changed | index | 0 | unchanged |
| Move | Element moved | target index | source index | unchanged |
| Reset | Cleared | 0 | 0 | 0 |
The following subsystems are deliberately out of the unified sink:
| # | Anti-pattern | Consequence | Correct approach |
|---|---|---|---|
| AD1 | Sink throws to interrupt the business | Exception is silently swallowed; business proceeds normally | Don't throw from a sink. To interrupt the business, surface state through a real Property (e.g. last_error) |
| AD2 | Constructing a heavy payload BEFORE checking has_trace_sink() | Pay the cost even when nobody's listening | Always gate with if (has_trace_sink()) { ... publish_trace_unchecked(...); } (D-24) |
| AD3 | Sink performs heavy work (file I/O, network) | Slows the hot path (every reactive flush / list mutation triggers it) | Sink should enqueue lightly; offload heavy work to a background thread |
| AD4 | Test calls install_trace_sink and forgets to clear | Subsequent tests pick up stale events | Use ScopedTraceSink for automatic restoration |
| AD5 | Cross-thread sink invocation that assumes thread-safety | Sink internals race | Assume the sink may be called from any thread; lock internally |
Each diagnostic-protocol invariant has an executable owner. All four targets below are implemented and run in the ordinary suites — the fuzzers as part of aria_fuzz (ctest fuzz_tests), the bench as part of scripts/check-bench.sh.
| Invariant | Owner | Where |
|---|---|---|
| D-21 install/clear never lets publish touch a destroyed sink | fuzz_trace_sink_install_race | modules/core/fuzz/fuzz_trace_sink_install_race.cpp |
| D-22 sink throws do not propagate | fuzz_trace_sink_throw_swallow | modules/core/fuzz/fuzz_trace_sink_throw_swallow.cpp |
| D-23 nested ScopedTraceSink restores in reverse order | fuzz_trace_sink_scoped_nesting | modules/core/fuzz/fuzz_trace_sink_scoped_nesting.cpp |
| D-24 zero-overhead fast path | aria_bench_trace_sink | benchmark/bench_trace_sink.cpp |
Notes on what these actually assert, because the naive version of each passes vacuously:
Iteration counts follow fuzz_support.hpp: 50k per fuzzer by default, ARIA_FUZZ_ITERS=1000000 for nightly / pre-release runs.
Every diagnostic-protocol change MUST flow as: doc change → code change → test change. Any new TraceCategory MUST first be registered in the D-1 table here; new payload fields are recorded under D-11.