|
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 lifecycle and threading. When any API comment says "see the lifecycle doc", it means this file. The document is split along two axes:
- Threading contract: which thread can call what, what actions cross threads, and the legal cross-thread paths.
- Lifetime contract: object construction/destruction order, subscription detach timing, and callback visibility boundaries.
Every contract item has a unique number (L-N) for citation in code, tests, and the CHANGELOG. The "MUST / MUST NOT / MAY" wording follows RFC 2119 conventions.
Aria's concurrency model has a single load-bearing axis: the reactive graph is pinned to a single thread; every other subsystem interacts with the graph thread through an explicit protocol.
Anything that violates this model is listed in the "Anti-patterns" section. Any user code that finds itself in an anti-pattern position has broken the contract; the framework does not promise well-defined behaviour.
reactive::Graph is a process-wide singleton. Every public method on a Graph instance MUST be called from the same thread. That thread is called the graph thread, implicitly chosen by the first thread that touches a public method, and every subsequent call MUST come from the same thread.
Graph::assert_on_graph_thread() checks via assert in Debug builds; Release builds are zero-overhead.
Scope: every operation below is a graph-thread action:
Anti-pattern: calling prop.set(x) from a worker thread. Legal path: prepare data on the worker, then co_await schedule_on(ui_executor) to hop to the graph thread, and write the Property there.
The single legal path for triggering a graph mutation from another thread is: post a callback to the graph thread via any aria::IScheduler::schedule (including the IDispatcher::post / IExecutor::post aliases) and call graph APIs from inside that callback once it resumes on the graph thread.
AsyncCommand and BindingEngine (SmartMarshal / AlwaysPost policies) internalise this path — prop = result; inside a coroutine works without a manual post provided the previous co_await schedule_on(ui) has completed.
| Subsystem | Threading model | Lock |
|---|---|---|
| reactive::Graph (Property / Computed / Effect) | Single thread (graph thread) | none |
| abi::SignalErased (ObservableList, Command::can_execute, EventBus, IView::on_destroy) | Multi-thread safe | std::mutex (snapshot then release on emit) |
| ObservableList<T> structural mutations | Multi-thread safe | std::shared_mutex |
| IDispatcher / IExecutor / IDelayedScheduler (all IScheduler) | Multi-thread safe | implementation-private |
Implications:
BindingEngine provides three VM→View routing policies. The default is Direct (zero overhead, assumes single-threaded MVVM). Production-recommended is SmartMarshal:
| Policy | Behaviour | Use case |
|---|---|---|
| Direct | Inline call, assumes the emit is already on the UI thread | Pure single-threaded; tests |
| SmartMarshal | Inline if dispatcher.is_main_thread() is true, otherwise post to the main thread | Production; coexists with a worker pool |
| AlwaysPost | Always post to the dispatcher | Tests that need deterministic "emit→post→update" ordering |
View→VM uses the same policy, including scalar, converted-text and command callbacks. HTTP callbacks originate on workers, so configure a real graph-thread dispatcher with SmartMarshal or AlwaysPost. Borrowed text is copied before posting. Per-view weak tokens discard queued work after clear, view replacement/destruction or engine teardown. Binding setup and teardown still belong on the graph owner thread; the token does not own the model/view.
AsyncCommand MUST take a graph-safe executor as its ui argument (is_safe_graph_executor_v checks at compile time); its internal coroutine template looks like:
The final schedule_on(ui) is the contractual guarantee for the command's Property write-back. If the user passes InlineExecutor as ui while the worker is not inline, the compile-time static_assert and the runtime check_executor_safety_runtime both reject it.
Platform executors and delayed schedulers MUST be installed before any AsyncCommand-owning view model is constructed.
AsyncCommand validates its executors in its constructor, not on first execution. Constructing a view model that owns one before a real main-thread IExecutor exists therefore throws std::invalid_argument at construction time:
AsyncCommand: cannot use InlineExecutor as the graph-thread executor when worker runs on a different thread. Remedy: install a real main-thread executor BEFORE constructing this view model …
Correct host startup order:
In tests and console applications MainThreadExecutor plays the role of step 2 — it is already GraphSafe | MainThread | Pumpable.
Aria states this ordering contract; the host enforces it. There is deliberately no bootstrap/orchestrator class: the startup sequence a real application needs also covers module loading and view registration, neither of which Aria owns.
Every scheduler abstraction in the framework virtually inherits from aria::IScheduler:
A caller checks capability in one line: if (has_caps(s, SchedulerCaps::Delay)) .... Where degradation is not acceptable, require_caps(s, ..., "context") throws unsupported_capability. Concrete capability sets:
| Concrete | caps() |
|---|---|
| InlineExecutor | Post \| GraphSafe \| WorkerSafe |
| ThreadPoolExecutor | Post \| WorkerSafe \| Autonomous |
| MainThreadExecutor | Post \| GraphSafe \| WorkerSafe \| MainThread \| Pumpable |
| VirtualTimeExecutor | Post \| Delay \| GraphSafe \| WorkerSafe \| Pumpable |
| SimpleDispatcher | Post \| Delay \| MainThread \| Pumpable |
| QtDispatcher | Post \| Delay \| MainThread \| Autonomous |
IDelayedScheduler::post_after(delay, fn) (alias of schedule_after) promises:
aria::Subscription is the unified RAII detach handle: move-only, not copyable.
A reactive-backed Subscription owns std::shared_ptr<ReactionNode>. Releasing the node triggers Node::~Node → clear_sources() → detach_edge, all of which are graph operations (L-1 applies).
Therefore: subscriptions returned by the reactive subsystem (including those moved out of Effect::into_subscription()) MUST be destroyed on the graph thread.
Current state: this contract is implicit in the common pattern "the VM drops its SubscriptionBag in its destructor" — VMs typically live on the graph thread. But there is no explicit assert for the destruction thread of a reactive-backed handle. P0-ε MUST add a stress fuzzer for it (an "observer-destroy-from-wrong-thread" fuzzer).
Signal-backed Subscription destruction is lock-protected and multi-thread safe.
SubscriptionBag explicitly disconnects handles in reverse insertion order; this does not depend on a standard library's vector destruction order. Implications:
Every abi::SignalErased-backed emit (ObservableList, EventBus, Command::can_execute_changed, IView::on_destroy, TypedSignal) uses snapshot-then-invoke:
Implications:
Reactive backend differs: the graph processes via pending_ in topological order. See L-20.
Allowed, but the user must avoid infinite recursion. emit does not hold the lock; recursive emit is just another snapshot-then-invoke.
abi::SignalErased holds its control block via shared_ptr; during emit the snapshot keeps owning entry references. Destroying the signal invalidates every connection, so later callbacks are skipped while an already executing callback retains its storage until it returns. Capture destructors run outside the signal lock.
disconnect_via_weak is a no-op when the control block is gone.
Built-in node destructors call retire_() before destroying their members. Retirement invalidates queued/work/tracker handles and detaches both incoming and outgoing edges while their backing storage is still valid. The base Node destructor repeats retirement safely as a fallback. Custom subclasses with edge storage or user-owned captures must retire at destructor entry.
AutoComputed / AutoReactionNode collect reads under a fresh TrackerScope while retaining their previous dependency set. After the user callback succeeds, they replace the old edges with the new dependencies. A throwing callback therefore retains the previous edges and can be retried on the next source change.
Implications:
Anti-pattern: capturing a reference to a source inside the recompute body and reading it AFTER recompute returns — that read registers on the outer tracker (if any), not on this Computed.
Effect::Effect(Fn) immediately invokes fn once to gather the initial dependency set (consistent with MobX autorun and SolidJS createEffect). If fn throws on the first run, Effect's constructor throws — the node was constructed but never assigned to Effect::node_, so no orphaned node remains.
| API | First-fire behaviour | Subsequent |
|---|---|---|
| prop.on_changed(fn) | Does NOT call fn | Calls fn on every actual change |
| prop.bind(fn) | Synchronously calls fn(value) once | Same as on_changed |
| prop.observe(fn) | Does NOT call | Calls fn(old, new) on every change |
| Computed::on_changed(fn) | Does NOT call (suppressed via a "primed" flag) | Calls fn on every actual change to the computed value |
| Computed::bind(fn) | Synchronously calls fn(get()) once (inside the graph) | Same as on_changed |
Note: Computed::bind's initial call happens inside the graph, but is not auto-wrapped in a batch — if the user triggers a prop.set chain inside the initial bind, they must wrap it in batch themselves.
Graph::flush is non-reentrant: once flush starts, flushing_ = true. While flushing, Property::set does NOT trigger another flush; the change goes into pending_ for the current flush's next round.
Implications:
Property<T>::set(v) is a no-op when value_ == v: no version bump, no push-color, no notification. Implications:
mutate(fn) does NOT do the equality check — it always fires. Designed for container-typed Properties.
Computed<T>::recompute() does NOT bump version_ when the new value equals the cached value; downstream observers are not notified. Graph::pull's MaybeDirty fast path: "every upstream's `observed_version == source.version()`" → mark Clean without calling recompute. This is the key to a glitch-free graph.
ObservableList<T> automatically installs a per-item subscription when T satisfies t.on_changed(fn). The install step happens AFTER the write lock is released — so if T is a Property (whose bind-style semantics fire once synchronously on subscribe), the callback that calls back into index_of_raw_ (which takes a shared_lock) will NOT deadlock with the just-released write lock.
This is the load-bearing invariant for list-reactive interop. Any future change to ObservableList MUST preserve the rule "install subscriptions outside the write lock".
Insert / Remove / Replace / Move / Reset / ItemChanged use the owning event protocol in list-diff-contract.md. List fanout is serialized: nested mutations follow the current batch and fanout, with incremental mirror coordinates and retained item/snapshot payloads. Subscription cancellation follows L-13. For batch operations such as insert_range / remove_range / remove_all, multiple single-element events are emitted; each index belongs to the receiver's incrementally replayed mirror. The producer may already contain the batch's final state.
Anti-pattern: an observer assumes "list size = idx + 1" upon receiving Insert(idx=2) — wrong, the list may already be larger.
The five derived-list owning callback types (FilteredList<T>::Predicate / SortedList<T>::Comparator / MappedList<S,T>::Mapper / DistinctList<T,K>::KeyOf / GroupedList<T,K>::KeyOf) all use aria::inplace_function<…, 32>; capacity overflow is a compile-time static_assert. The "derived-list hot-path callback never triggers malloc" property is type-system enforced, NOT a documentation promise.
Contract:
Every framework-internal "must stay `noexcept` yet calls into a user callback" boundary (ThreadPoolExecutor::worker_loop_ / MainThreadExecutor::drain / MainThreadExecutor::run_one / SimpleDispatcher::pump / SimpleDispatcher::run_one / VirtualTimeExecutor::advance / VirtualTimeExecutor::run_until_idle / aria::abi::SlotErased::invoke's trampoline / async detached path) funnels through aria::report_callback_failure(category, std::current_exception()) — bare catch (...) { /* swallow */ } is no longer used.
Contract:
Anti-patterns:
Future: if a host wants to enrich the sink with source-line / thread-id / stack-capture, it can swap the sink without touching any boundary. That's the actual value of the unified channel — all reporting paths converge behind one sink.
When a view is destroyed: IView::~IView → fire_destroy_() → destroy_signal_.emit(). The emit fires at the start of the IView base destructor; handlers MUST NOT touch derived-class state (the derived part has already destructed).
BindingEngine subscribes to on_destroy from bucket_for_(view):
Implication: between dispatcher.post(fn) and the actual execution of fn, the view may die. fn checks alive_token.expired() and is silently dropped.
Strongly recommended (not enforced): native adapters SHOULD proactively call fire_destroy_() when the native handle is released. The fallback fire from IView::~IView is too late — the derived state is already gone.
aria::binding::Converter<T,U>'s View → Model channel used to be "`std::stoi/stod` throws → `catch(...)` → return `T{}`", which silently wrote 0 / 0.0 into the ViewModel on bad input — the business code couldn't tell "user typed 0" from "input is invalid". Sprint4-#1 promoted this into an observable, non-corrupting contract:
ViewModel::~ViewModel order:
Implications:
ViewModelScope::attach(vm) registers a hook via add_destroy_hook; the hook calls keep->cancel_and_join() to synchronously cancel and wait for every coroutine spawned by that scope to exit (with a 5-second timeout). on_cancel still fires synchronously on the VM destruction thread — its job is signal propagation. The "wait for every coroutine to exit" part is handled by CoroutineScope's internal inflight counter + condition variable.
Implications:
CoroutineScope is now a real structured-concurrency primitive, not the lightweight "fire and forget" wrapper it used to be. Contract:
CancellationSource::cancel()'s own semantics are unchanged:
on_cancel fires immediately if cancellation already occurred: the implementation does a lock-free is_cancelled() first and fires synchronously when already cancelled; even if cancellation lands while holding the lock, the rechecked-and-fire path catches it. See CancellationToken::on_cancel in cancellation.hpp. This is directly load-bearing for the parent-child cascade — if a parent cancels before the child registers, the child fires its own cancel right away rather than missing it.
Each execute() creates a per-invocation CancellationSource that registers into the state's invocation_sources list.
Implications:
Strong contract: AsyncCommand MUST die before the ui executor it depends on. The usual pattern is "ui executor as an app singleton, AsyncCommand as a VM member".
AsyncResource<T, Key> maintains cache + dedupe and shares the same dual-executor model as AsyncCommand (L-37). Its public observable surface is four equality-gated Properties (is_loading / error / error_message / data) plus a synthesised Property<Loadable<T>> that always agrees with them (LO-1).
Generation / staleness (R-1): every fetch() / refresh() bumps an atomic gen. A run reads its my_gen at launch and, after the final schedule_on(ui) hop, compares it against the live gen. Only the latest run wins; a stale run drops its result and MUST NOT clear in_flight — the newer run owns the flag and clears it on its own completion. This keeps is_loading continuously true across rapid key changes instead of flickering.
Cancellation axes: AsyncResource participates in all three lifetime axes (see "Three-axis cancellation" below):
Strong contract (same as L-37 A5): AsyncResource MUST die before the ui executor it depends on. The usual pattern is "ui executor as an app singleton, resource as a VM member".
In-flight async work is cancelled along exactly three independent lifetime axes. Each has a distinct owner and trigger; they compose without overlap.
| Axis | Owner | Trigger | Mechanism |
|---|---|---|---|
| VM scope | ViewModelScope | VM destroy hook (L-34/L-35) | CoroutineScope::cancel_and_join() |
| Navigator entry | Navigator entry | entry pop | the entry's CancellationSource |
| View destroy | the IView | native handle released → IView::on_destroy | BindingEngine::bind_view_lifetime(view, cb) runs cb once |
The view-destroy axis is the one a naive MVVM framework misses: a user navigates away from a sub-view inside a still-living page (so neither the VM nor the Navigator entry is torn down), yet an in-flight AsyncCommand / AsyncResource is still running and would resume against a dead view. bind_view_lifetime closes it:
Layering note: BindingEngine itself is deliberately async-agnostic — bind_view_lifetime takes a plain std::function<void()> and the engine never reaches into AsyncCommand. (The binding module does link aria-async, because sibling facilities like ViewModelScope and Navigation need coroutine cancellation primitives; but the binding engine and its bind_* methods stay free of any async type.) So the host wires the async side in one explicit line rather than the engine reaching into AsyncCommand. There is no async-aware bind_* overload on the engine; if one were ever wanted it would belong to a layer above (the app, or a future aria-app umbrella), not to BindingEngine. See ROADMAP P1-H.
IViewAdapter implementations MUST honour two invariants:
Why warn is NOT rate-limited: the unsupported branch only fires when "the user bound the wrong widget" — that's a real diagnostic signal. Throttling it would erase the difference between the first and the ten-thousandth occurrence and wipe out its diagnostic value. Adapters are hot paths only on the "matched widget" branch — that path bypasses the warn helper.
Why all three adapters must be symmetric:
Type recognition:
runtime::Container releases its registrations in reverse registration order, in both clear() and ~Container(). A service registered after its dependency is therefore destroyed before it, so the rule for hosts is one sentence: register providers before consumers.
Two further guarantees hold during teardown:
Singleton registrations and factory registrations share one teardown order; they are not two independently cleared tables. Re-registering a type replaces the value and destroys the previous one immediately (also outside the lock) but keeps the type's original position — the instance behind the type changed, the dependency order did not.
Scope limit — this is registration order, not the resolution graph. Aria does not record who resolved whom during construction, so a consumer registered before the provider it resolves still tears down in the wrong order. That is a caller-side bug the container cannot detect. Full dependency-graph-ordered teardown is deliberately out of scope (see docs/ROADMAP.md → Evaluated and declined).
Container has no global() accessor — every instance is explicitly owned by its caller — so this contract is per-instance and introduces no process-wide state.
Pinned by test_container.cpp; reverting teardown to forward order (or to unordered_map::clear()) fails five of its cases, including the re-entrant-destructor case, which deadlocks rather than merely mis-ordering.
| # | Anti-pattern | Consequence | Correct approach |
|---|---|---|---|
| A1 | prop.set(x) from a worker thread | Debug trips assert_on_graph_thread; Release is undefined | First co_await schedule_on(ui) |
| A2 | Reactive Subscription destructed off the graph thread | Node detach happens on the wrong thread, can race graph internals | Destruct on the graph thread (e.g. destroy the VM there) |
| A3 | Multi-node cycle: Effects/Computeds writing each other's read source (A→B→C→A) | After 100 rounds: CircularDependencyError | Redesign the dependency direction; use peek to read prior frame |
| A3b | An Effect body does prop.set(prop.get()+condition) writing its own read source | Does NOT trigger a cycle (the clear_sources pattern dodges it), but every external write makes fn run twice — once from the external write, once from the Effect's own set in the next round. Can violate user expectations | Use peek(), or an explicit idempotent guard, or redesign |
| A4 | Touching derived-class members in an IView::~IView handler | Derived has already destructed → UAF | Call fire_destroy_() early in the native destructor |
| A5 | The ui executor dies right after AsyncCommand | The write-back post fails or leaks | AsyncCommand MUST die before the ui executor (see L-37) |
| A6 | prop.set inside a Computed body that loops the dependency graph | Same as A3 | Wrap in batch or redesign |
| A7 | Direct BindingEngine + multi-thread emit | A worker thread may touch a native widget — UB | Use SmartMarshal or guarantee emit on the UI thread |
| A8 | ObservableList slots holding cycles (slot.item points back at itself or each other) | Never released, leak | Avoid on the business side; the framework does not police it |
| A9 | The same Subscription moved-from multiple times / destructed across threads | move-only forbids copy, but cross-thread move can still UAF (reactive backend) | Hold on the graph thread; if needed, post a reset task through the dispatcher |
| A10 | Destroying a Reaction node currently being scheduled inside a reactive flush | The round vector holds raw pointers — potential UAF | Don't bag.clear() your own Effect from inside that Effect's body |
| A11 | An adapter destroying its cached IView wrappers while holding its own mutex | ~IView fires on_destroy, whose handlers include the adapter's own bridge cleanup — which re-locks that mutex and self-deadlocks | Move the cache out under the lock, then destroy it after the lock is released (see QtAdapter / AppKitAdapter / UIKitAdapter teardown, and test_appkit_view_for.mm) |
P0-ε must add a fuzzer for each of the contracts below:
| Invariant | Fuzzer | Goal |
|---|---|---|
| L-13 unsubscribe-during-emit | signal_unsubscribe_during_emit_fuzzer | 1M emits × random disconnects, no UAF / no missed disconnect |
| L-17 dynamic dependencies | computed_dynamic_dep_fuzzer | 1M random branch flips, no ghost subscriptions |
| L-20 set during reactive flush | reactive_reentrant_set_fuzzer | Recursive set depth ≤ 50, eventually stable or CircularDependencyError |
| L-32 binding view-destroy race | binding_view_destroy_race_fuzzer | 1M emit-vs-destroy interleavings, no UAF |
| L-31 list mutation storm | observable_list_mutation_storm_fuzzer | 1M random insert/remove/move/replace, listeners stay consistent |
| L-36 structured concurrency | coroutine_scope_drain_fuzzer | 1M launch/cancel/join interleavings; no missed callback, no missed accounting, inflight_count converges to 0 |
| L-37 async command cancel/dtor race | async_command_dtor_fuzzer | 1M dtor-vs-execute interleavings, no UAF / no leaked coroutine |
Any failure here = contract break. Fix the code, not the test.
This document is part of the P0-β deliverable. Any future lifecycle-related change MUST flow as doc change → code change → test change; the reverse is forbidden (to prevent "code drifts first, doc catches up later" contract decay).