|
Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
|
This document explains the design principles behind aria.
The compiled foundation owns type-erased signals, version metadata, the process-wide reactive graph, diagnostic storage, and the delayed scheduler base. It follows ARIA_BUILD_SHARED: shared by default, static for a statically linked application. aria::core links this complete foundation; using a Property does not require linking runtime services.
Shared modules must link the same foundation library. Independently linking static copies into multiple plugins creates separate global state. C linkage does not bridge incompatible C++ standard libraries: hosts and adapters must use compatible compiler, standard-library, runtime, and configuration ABIs. The 2.0.0 boundary uses ARIA_ABI_VERSION == 2.
Header-only templates that build on a single reactive dependency-graph engine (see aria/reactive/):
The engine supports reactive::batch([&]{ ... }) for grouping writes into one notification sweep, and reactive::untracked([&]{ ... }) for reads that should not register a dependency edge.
C++ coroutine support, available in both C++20 and C++23 modes. Built around a minimal Task<T> (lazy, single-shot, exception-safe), an IExecutor interface, and a ThreadPoolExecutor. The schedule_on(executor) awaitable lets you hop between threads with co_await.
Process-wide services compiled into one shared library:
Putting these in a shared library guarantees one instance per process even when the process loads multiple plug-ins.
Translates between view-models (Property/Command) and platform widgets via IViewAdapter. Each adapter implements a small set of read/write/observe operations; BindingEngine glues them to bind one-way and two-way.
Qt6, AppKit and UIKit are all production-grade adapters:
The HTTP adapter (aria::adapters::http::HttpAdapter) is structurally the same as Qt/AppKit/UIKit — same IView and IViewAdapter contract — but the "native handle" it wraps is a logical string id, not a widget pointer. State pushed through the IViewAdapter setter API fans out via Server-Sent Events to every connected browser; inbound REST requests dispatch back into the registered observer callbacks the same way Qt's editingFinished does.
The implementation is intentionally compact: the HTTP/1.1 + SSE server is provided by the vendored single-header [cpp-httplib] (third_party/cpp-httplib/httplib.h, MIT) and all JSON encode/decode by the vendored single-header [nlohmann::json] (third_party/nlohmann_json/, MIT). Both are header-only and committed into the tree, so enabling the adapter adds no new external build-system dependency beyond what already ships in third_party/ (HTTPS additionally builds the vendored OpenSSL when ARIA_HTTP_ENABLE_TLS=ON). What aria owns lives in a single http_adapter.cpp: the wire protocol, the view registry + shadow state, subscription dispatch, and the SSE fan-out across connected clients. The browser side is a vanilla-ESM SDK (modules/adapters/http/web-sdk/aria_client.js) that works in any modern browser without a build step.
This adapter is the right shape for "C++ stays on a server, browser is a thin client" — desktop apps with a web admin panel, headless services with a debug UI, local debug dashboards. The (still-planned) WASM adapter is reserved for the opposite shape — "C++ runs inside the browser sandbox" — and is subject to hard sandbox constraints (CORS, no native sockets, no thread preemption, OPFS-only storage) that make it unsuitable for many real workloads. See RFC 0001 for the protocol design.
A common bug in observer-based frameworks is calling unsubscribe() after the publisher has been destroyed. aria prevents this by design:
The shared handle owns its disconnect action. Dropping the handle calls the deleter, which itself only touches the publisher through a std::weak_ptr — so if the publisher is already gone, disconnect is a no-op. There is no type-erased pointer chasing and no risk of use-after-free.
This is verified by the unit test Property: subscription survives Property destruction.
aria is not monolithic in its concurrency story — different subsystems make different trade-offs, and the division is deliberate:
The reactive engine runs on exactly one "graph thread", typically the UI thread of your application.
To update a Property from a background thread, marshal through the runtime's Dispatcher:
The aria-async coroutine helpers make this even cleaner:
(ObservableList, EventBus, Command's CanExecuteSignal.)
These live outside the reactive graph and have always been free to cross threads. Their internal abi::SignalErased takes a brief unique-lock for emit/connect/disconnect, but observer callbacks are invoked without holding any publisher lock, so handlers can touch the publisher (or any other signal) without deadlock.
Command predicate still runs on the graph thread. The CanExecuteSignal itself is thread-safe and can be observed from anywhere, but the predicate installed in a Command<> reads Property / Computed values, so the internal Effect that auto-tracks it must fire on the graph thread. In practice this is automatic: the reactive graph is single-threaded, so the Effect runs wherever writes to its upstream Properties happen — which is exactly where the docs require them to happen.
ObservableList<T> in particular uses std::shared_mutex internally so snapshots, lookups, and mutations are safe from any thread. It is deliberately not a reactive-graph node: a collection-level change notification is coarser than the per-value versioning the graph uses, and the lock-based model composes naturally with the async runtime.
The "ABI stable" promise in the README applies to a specific set of boundaries, not to every type in the repo:
| Layer / symbol | ABI status |
|---|---|
| aria-abi (SignalErased, version metadata) | stable within a major version |
| aria-runtime non-template exports (EventBus, Container, Dispatcher, Logger) | stable within a major version |
| aria-binding non-template exports (BindingEngine type-erased parts, IViewAdapter vtable) | stable within a major version |
| aria-core templates (Property<T>, Computed<T>, Command<...>, ObservableList<T>) | source-compatible, not binary-stable — they are instantiated in the user's TU |
| IView base class (data members destroy_signal_, fired_) | tied to aria-binding; adapters recompiled with a new binding release are expected |
In other words: if you ship a plug-in that only talks through aria-runtime / aria-binding vtables and ABI handles, it can continue to work across minor upgrades. If you ship a plug-in that inlines Property<T> or derives from IView in its own DLL, treat every minor release as a rebuild boundary.
The ownership rules divide along the same lines as the threading model.
Dependencies between nodes are held as intrusive doubly-linked edges (reactive::Edge), not as reference-counted handles. A Subscription returned from Property::on_changed / Effect::into_subscription / Computed::bind is a shared_ptr to the Reaction node; dropping the handle destroys the node, which in turn detaches every edge in O(deps). Because edges are intrusive, the publisher never owns the subscriber and vice-versa — there are no reference cycles to leak.
These use the ABI-stable abi::SignalErased, whose control block is a shared_ptr. A Subscription keeps only a weak_ptr to that control block:
ObservableList<T> in particular holds shared_ptr<T> for its elements and creates per-item subscriptions whose handlers also go through the same weak-handle dance, so a destroyed list never leaks dangling slots on its items.
Taken together these rules guarantee:
BindingEngine sits between the ViewModel side (Property, Command) and the platform view side (IView). It takes responsibility for one of those two sides only:
Expansion follows the concrete-consumer triggers in the roadmap:
Aria is split across modules with deliberately different stability contracts. The short version:
| Module | Surface | Stability |
|---|---|---|
| aria-abi | C-style header (aria/abi/*.hpp), no templates | True ABI: stable across minor versions, breaks only on a major bump. The macro ARIA_ABI_VERSION is the source of truth. |
| aria-runtime | Non-template runtime services (Container, Dispatcher, Logger, EventBus glue) | True ABI: stable like aria-abi. The dynamic library can be replaced under a consumer at install time. |
| aria-binding | BindingEngine, IViewAdapter, IView (non-template) | True ABI: stable like aria-abi / aria-runtime. |
| aria-core | Property<T>, Computed<T>, Command<...>, ObservableList<T>, FilteredList<T>, SortedList<T>, MappedList<S,T>, reactive graph internals | Source-only: every header is compiled into the consumer's translation unit; the symbols emitted are mangled by T and live in the consumer binary. No binary compatibility across builds is offered or required. |
| aria-async | Task<T>, AsyncCommand<R, Args...>, with_timeout, retry*, CancellationToken | Source-only, same reasoning. |
| aria-adapters-* | Qt6 / AppKit / UIKit glue | Source-only at the moment. AppKit / UIKit modules ship as INTERFACE / OBJECT targets (compiled inside the consumer Xcode app); all three adapters are production-grade and pinned under the same adapter_conformance battery. A future packaging change may move them behind aria-binding's ABI line once Windows DLL parity is needed. |
A consumer that links against libaria_runtime.X.dylib can be upgraded to libaria_runtime.Y.dylib (where X and Y share a major version) without recompiling, provided:
There is no abi-dump job. An earlier revision of this document claimed one existed on tagged releases; it never did, and scripts/ contains no such tool. What CI actually runs is the abi-smoke job: it builds with ARIA_BUILD_SHARED=ON and executes cross_dylib_abi_smoke, which exercises IProperty<T> across a real dylib boundary. That catches gross breakage (missing symbols, vtable mismatch on the smoked interfaces) but does not diff exported symbol sets between releases.
For the runtime half, aria::abi::runtime_abi_version() and runtime_version_string() are compiled into the library, so a host that loads aria dynamically can assert agreement with its headers at startup via aria::abi::abi_matches_headers(). That is the enforceable part; a genuine symbol-set diff on tagged releases remains unimplemented, and the guarantee above should be read as a maintenance intent rather than a verified property.
The layout guarantee also assumes a consumer built with the same compiler and standard library as the library itself — IViewAdapter's virtual signatures pass std::string and std::function by value, so a mixed-stdlib link is outside the promise regardless of ABI version.
Source-only modules (aria-core, aria-async, aria-adapters-*) are exempt — their symbols recompile inside each consumer.
A type-erased IProperty virtual interface (get_any / set_any / subscribe_any / type) lets plug-ins observe and mutate Property<T> instances without sharing the concrete T. aria::Property<T> inherits from aria::IProperty and implements all four virtuals; the cost is one virtual call plus a std::any round-trip per access.
The cost numbers (Apple Silicon, -O3, single-threaded bench_iproperty):
| Operation | ns/op |
|---|---|
| Property<int>::peek() | ~0.3 |
| Property<int>::get() | ~1.4 |
| IProperty::get_any() + any_cast<int> | ~6.0 |
| Property<int>::set(i) | ~180 |
| IProperty::set_any(any{i}) | ~187 |
Type erasure adds ~5-7 ns over the template path on get / set for trivially-copyable payloads (the int payload fits in std::any's SBO). Larger payload types pay for an additional heap allocation when std::any spills out of its SBO. Either way, template-direct should still be preferred in hot paths inside the host module; plug-in / RPC / live-binding boundaries are the right place to pay the type-erasure cost.
A cross-dylib smoke test (load a plug-in and operate on its IProperty* from the host) is not shipped today. It is gated on a real plug-in driver appearing — without one, building the necessary CI matrix to validate libc++ ↔ libstdc++ ABI parity is all cost and no signal. The interface is fully usable inside a single binary today; cross-dylib will land when there is a real customer for it.
The header lives at modules/core/include/aria/i_property.hpp; the implementation is inline in modules/core/include/aria/reactive/property.hpp; the bench is benchmark/bench_iproperty.cpp; the tests are modules/core/tests/test_i_property.cpp.
aria-async ships several awaitable combinators that resolve the parent coroutine from one of multiple signaller paths:
| Combinator | Signallers |
|---|---|
| with_timeout(..., OnTimeout::Cancel) | inner coroutine (cooperative; deadline only flips a token) |
| with_timeout(..., OnTimeout::Fail) | inner coroutine vs deadline timer |
| when_any(...) | N input tasks, first wins |
| when_any_cancellable(...) | N factories receiving tokens; winner cancels losers |
All race-style combinators share a single primitive — detail::RaceSlot<R> in modules/async/include/aria/async/detail/race_slot.hpp — and a two-phase publication protocol (hardened after a peer-review found a publication race in the original single-atomic design):
Why two atomics instead of one: the original protocol stored the winner code BEFORE result was written, so a parent observing winner_ != 0 via await_ready() (which does NOT take mu_) could rush into await_resume() and read an unpublished result — UB. Splitting into "claim" (writer-resource lock, internal) and "winner / published" (release-store AFTER result is written, parent-observed under acquire) closes the window without changing the public awaiter API.
mu_ does NOT serialise result publication — that is handled by acquire/release on winner_. mu_ serialises the orthogonal handle-handoff race so exactly one of {publisher.resume, parent.skip-suspend} fires.
await_suspend returns false (skip suspension) whenever it re-checks winner_ under mu_ and finds the slot already published. This is the standard C++ coroutine-protocol opt-out for "the work is already done; do not park me".
when_all waits for ALL inputs to complete and lacks a winner CAS. Its await_suspend publishes the parent handle through an atomic (detail::WhenAllParentHandle, a std::atomic<void*> with release-store / acquire-load) before spawning any driver. Drivers load the handle only after the remaining_ atomic counter hits zero, so even a synchronously-completing single-task when_all(...) observes the published handle. Because the handle now lives in an atomic, the handoff is TSan-clean and its correctness no longer depends on the ordering of the two halves of await_suspend — the store-before-spawn order is retained purely as an optimisation.
The race model is implemented as a single shared RaceSlot<R> primitive plus the two-phase publication protocol described above. All race-style awaiter await_suspend paths are audited for the publication-window hazard and covered by test_race_slot_publish plus the TSan stress harness.