Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
inspector.hpp
Go to the documentation of this file.
1#pragma once
2
3// ============================================================================
4// reactive/inspector.hpp
5// ----------------------------------------------------------------------------
6// `GraphInspector` is a diagnostics-only facade over the reactive graph.
7//
8// The reactive engine is intentionally opaque at runtime: nodes are owned
9// by their Property / Computed / Effect host objects, edges are intrusive,
10// and the Graph does not keep a global registry (both for performance and
11// to avoid lifetime pitfalls). That opacity becomes painful the first
12// time a user asks "why did Computed<X> not recompute?" or "why did the
13// Effect fire twice after a single set()?"
14//
15// Inspector addresses this in three small, independently useful pieces:
16//
17// 1. **Structural snapshot** -- `to_dot()` / `to_json()` walk the DAG
18// from a set of seed nodes (typically the VM's Properties and
19// Computeds) and emit a textual dump of the reachable subgraph.
20// Open the .dot in Graphviz or pipe the JSON into any tool.
21//
22// 2. **Flush tracing** -- `install_flush_tracer()` registers a callback
23// the Graph invokes for every pull during a flush. Combined with
24// `Node::debug_name()`, this gives a complete, ordered record of
25// which nodes re-evaluated on each write, and in what round.
26//
27// 3. **Cycle path** -- on CircularDependencyError the Graph now
28// captures the pending chain so callers can log the exact node
29// names that formed the cycle.
30//
31// The inspector is header-only and opt-in: if you never call it, the
32// graph pays no cost. The flush tracer check is a single pointer
33// comparison on the hot path.
34// ============================================================================
35
38
39#include <chrono>
40#include <cstddef>
41#include <functional>
42#include <ostream>
43#include <sstream>
44#include <string>
45#include <string_view>
46#include <unordered_set>
47#include <utility>
48#include <vector>
49
50namespace aria::reactive {
51
55public:
56 // ------------------------------------------------------------------
57 // Structural snapshot
58 // ------------------------------------------------------------------
59
64 static std::vector<const Node*> reachable_from(
65 const std::vector<const Node*>& seeds) {
66 std::vector<const Node*> out;
67 std::unordered_set<const Node*> visited;
68 std::vector<const Node*> stack(seeds.begin(), seeds.end());
69
70 while (!stack.empty()) {
71 const Node* n = stack.back();
72 stack.pop_back();
73 if (!n || !visited.insert(n).second) continue;
74 out.push_back(n);
75
76 // Walk sources (upstream).
77 n->for_each_source([&](const Edge& e) {
78 if (e.source) stack.push_back(e.source);
79 });
80 // Walk observers (downstream). `for_each_observer` is a
81 // non-const member because it exposes mutable Edge& to
82 // `Graph` (which needs to touch observed_version); for a
83 // const-correct walk we go through the head pointer via a
84 // helper.
85 walk_observers_(n, [&](const Node* obs) {
86 if (obs) stack.push_back(obs);
87 });
88 }
89 return out;
90 }
91
96 static std::string to_dot(const std::vector<const Node*>& seeds,
97 std::string_view graph_name = "reactive") {
98 std::ostringstream os;
99 os << "digraph \"" << escape_(graph_name) << "\" {\n";
100 os << " rankdir=LR;\n";
101 os << " node [shape=box, style=rounded, fontname=\"monospace\"];\n";
102
103 const auto nodes = reachable_from(seeds);
104
105 // One node per reachable Node, labelled with kind/state/depth/name.
106 for (const Node* n : nodes) {
107 os << " \"" << reinterpret_cast<std::uintptr_t>(n) << "\" "
108 << "[label=\"" << escape_(label_for_(n))
109 << "\", " << style_for_(n) << "];\n";
110 }
111
112 // One edge per Edge record. We enumerate via each node's source
113 // list to avoid double-counting (every edge shows up once as a
114 // source of its observer and once as an observer of its source).
115 for (const Node* n : nodes) {
116 n->for_each_source([&](const Edge& e) {
117 if (!e.source || !e.observer) return;
118 os << " \"" << reinterpret_cast<std::uintptr_t>(e.source)
119 << "\" -> \"" << reinterpret_cast<std::uintptr_t>(e.observer)
120 << "\" [label=\"v=" << e.observed_version << "\"];\n";
121 });
122 }
123
124 os << "}\n";
125 return os.str();
126 }
127
132 static std::string to_json(const std::vector<const Node*>& seeds) {
133 std::ostringstream os;
134 const auto nodes = reachable_from(seeds);
135
136 os << "{\"nodes\":[";
137 bool first = true;
138 for (const Node* n : nodes) {
139 if (!first) os << ',';
140 first = false;
141 os << "{\"id\":" << reinterpret_cast<std::uintptr_t>(n)
142 << ",\"kind\":\"" << kind_name_(n->kind())
143 << "\",\"state\":\"" << state_name_(n->state())
144 << "\",\"depth\":" << n->depth()
145 << ",\"version\":" << n->version()
146 << ",\"name\":\"" << escape_json_(n->debug_name()) << "\"}";
147 }
148 os << "],\"edges\":[";
149
150 first = true;
151 for (const Node* n : nodes) {
152 n->for_each_source([&](const Edge& e) {
153 if (!e.source || !e.observer) return;
154 if (!first) os << ',';
155 first = false;
156 os << "{\"from\":" << reinterpret_cast<std::uintptr_t>(e.source)
157 << ",\"to\":" << reinterpret_cast<std::uintptr_t>(e.observer)
158 << ",\"observed_version\":" << e.observed_version << '}';
159 });
160 }
161 os << "]}";
162 return os.str();
163 }
164
165 // ------------------------------------------------------------------
166 // Flush tracing
167 // ------------------------------------------------------------------
168
171 struct FlushEvent {
172 enum class Phase {
173 FlushBegin,
174 RoundBegin,
175 Pull,
176 SkipClean,
177 Recomputed,
178 RoundEnd,
179 FlushEnd,
180 };
182 const Node* node = nullptr;
183 int round = 0;
184 bool changed = false;
189 long long duration_us = 0;
190 };
191
192 using FlushTracer = std::function<void(const FlushEvent&)>;
193
201 static void install_flush_tracer(FlushTracer tracer) {
203 if (!tracer) {
205 return;
206 }
207 // We adapt the user-facing `FlushTracer` (nice FlushEvent struct)
208 // onto the low-level `FlushTraceFn` the Graph dispatches to. The
209 // phase integer → enum mapping is the stable ABI boundary.
210 //
211 // `last_pull` is captured mutably inside the adapter: each
212 // Pull records its timestamp and the next Recomputed subtracts
213 // from it. Graph::flush is single-threaded (the graph-thread
214 // invariant) so a plain by-value mutable capture is enough —
215 // no heap allocation needed.
216 auto next = std::make_shared<FlushTraceFn>([tracer = std::move(tracer),
217 last_pull = std::chrono::steady_clock::time_point{}](
218 int phase_int,
219 const Node* node,
220 int round,
221 bool changed) mutable {
222 FlushEvent ev;
223 ev.phase = static_cast<FlushEvent::Phase>(phase_int);
224 ev.node = node;
225 ev.round = round;
226 ev.changed = changed;
227 if (ev.phase == FlushEvent::Phase::Pull) {
228 last_pull = std::chrono::steady_clock::now();
229 } else if (ev.phase == FlushEvent::Phase::Recomputed) {
230 const auto now = std::chrono::steady_clock::now();
231 ev.duration_us = std::chrono::duration_cast<std::chrono::microseconds>(
232 now - last_pull).count();
233 }
234 tracer(ev);
235 });
236 // Publish the replacement before releasing any user captures.
237 auto retired = std::exchange(flush_trace_hook_(), std::move(next));
238 }
239
240 static void clear_flush_tracer() noexcept {
242 auto retired = std::exchange(flush_trace_hook_(), {});
243 }
244
245 [[nodiscard]] static bool has_flush_tracer() noexcept {
247 return static_cast<bool>(flush_trace_hook_());
248 }
249
255 public:
256 explicit ScopedTracer(FlushTracer tracer) {
258 previous_ = flush_trace_hook_();
259 install_flush_tracer(std::move(tracer));
260 }
262 auto retired = std::exchange(flush_trace_hook_(), std::move(previous_));
263 }
264 ScopedTracer(const ScopedTracer&) = delete;
266 private:
267 std::shared_ptr<FlushTraceFn> previous_;
268 };
269
270 // ------------------------------------------------------------------
271 // Tiny convenience: ASCII dump for quick stderr debugging.
272 // ------------------------------------------------------------------
273
276 static std::string to_text(const std::vector<const Node*>& seeds) {
277 std::ostringstream os;
278 for (const Node* n : reachable_from(seeds)) {
279 os << "[" << kind_name_(n->kind()) << "]"
280 << " " << n->effective_debug_name()
281 << " depth=" << n->depth()
282 << " v=" << n->version()
283 << " state=" << state_name_(n->state()) << '\n';
284 }
285 return os.str();
286 }
287
288private:
289 static const char* kind_name_(NodeKind k) noexcept {
290 switch (k) {
291 case NodeKind::Source: return "Source";
292 case NodeKind::Derivation: return "Derivation";
293 case NodeKind::Reaction: return "Reaction";
294 }
295 return "?";
296 }
297
298 static const char* state_name_(NodeState s) noexcept {
299 switch (s) {
300 case NodeState::Clean: return "Clean";
301 case NodeState::MaybeDirty: return "MaybeDirty";
302 case NodeState::Dirty: return "Dirty";
303 case NodeState::Computing: return "Computing";
304 }
305 return "?";
306 }
307
308 static std::string label_for_(const Node* n) {
309 std::ostringstream os;
310 os << kind_name_(n->kind()) << '\n';
311 os << n->effective_debug_name() << '\n';
312 os << "d=" << n->depth()
313 << " v=" << n->version()
314 << " " << state_name_(n->state());
315 return os.str();
316 }
317
318 static const char* style_for_(const Node* n) noexcept {
319 switch (n->kind()) {
320 case NodeKind::Source: return "fillcolor=\"#cde4ff\", style=\"rounded,filled\"";
321 case NodeKind::Derivation: return "fillcolor=\"#ffe3b0\", style=\"rounded,filled\"";
322 case NodeKind::Reaction: return "fillcolor=\"#d8ffd8\", style=\"rounded,filled\"";
323 }
324 return "";
325 }
326
327 static std::string escape_(std::string_view s) {
328 std::string out;
329 out.reserve(s.size());
330 for (char c : s) {
331 if (c == '"' || c == '\\') { out.push_back('\\'); out.push_back(c); }
332 else if (c == '\n') { out += "\\n"; }
333 else { out.push_back(c); }
334 }
335 return out;
336 }
337
338 static std::string escape_json_(std::string_view s) {
339 std::string out;
340 out.reserve(s.size());
341 for (char c : s) {
342 switch (c) {
343 case '"': out += "\\\""; break;
344 case '\\': out += "\\\\"; break;
345 case '\n': out += "\\n"; break;
346 case '\r': out += "\\r"; break;
347 case '\t': out += "\\t"; break;
348 default:
349 if (static_cast<unsigned char>(c) < 0x20) {
350 constexpr char hex[] = "0123456789abcdef";
351 out += "\\u00";
352 const auto value = static_cast<unsigned char>(c);
353 out.push_back(hex[value >> 4]);
354 out.push_back(hex[value & 0x0f]);
355 } else {
356 out.push_back(c);
357 }
358 }
359 }
360 return out;
361 }
362
363 // Walk a node's observers via its intrusive list. `Node::for_each_observer`
364 // passes `Edge&` (non-const) because Graph needs that mutability; the
365 // inspector only needs the downstream Node*, so we read the head
366 // directly. This is safe: we never mutate, and the list walk is
367 // entirely sequential.
368 template<class F>
369 static void walk_observers_(const Node* n, F&& f) {
370 // const_cast is acceptable here: we only read the list; no Edge
371 // field is modified. The signature is non-const purely for
372 // mutability in the non-diagnostic callers.
373 const_cast<Node*>(n)->for_each_observer([&](const Edge& e) {
374 f(e.observer);
375 });
376 }
377};
378
379} // namespace aria::reactive
ScopedTracer(FlushTracer tracer)
Definition inspector.hpp:256
void assert_on_graph_thread() const noexcept
Definition graph.hpp:155
ScopedTracer(FlushTracer tracer)
Definition inspector.hpp:256
~ScopedTracer()
Definition inspector.hpp:261
ScopedTracer & operator=(const ScopedTracer &)=delete
ScopedTracer(const ScopedTracer &)=delete
Lightweight diagnostics entry point.
Definition inspector.hpp:54
static std::vector< const Node * > reachable_from(const std::vector< const Node * > &seeds)
Collect every node reachable from seeds through either direction of the dependency graph (upstream so...
Definition inspector.hpp:64
static void install_flush_tracer(FlushTracer tracer)
Install a tracer.
Definition inspector.hpp:201
std::function< void(const FlushEvent &)> FlushTracer
Definition inspector.hpp:192
static bool has_flush_tracer() noexcept
Definition inspector.hpp:245
static void clear_flush_tracer() noexcept
Definition inspector.hpp:240
static std::string to_dot(const std::vector< const Node * > &seeds, std::string_view graph_name="reactive")
Emit the reachable subgraph as a Graphviz DOT document.
Definition inspector.hpp:96
static std::string to_text(const std::vector< const Node * > &seeds)
Human-readable one-line-per-node summary, useful inside gdb or during ad-hoc printf debugging.
Definition inspector.hpp:276
static std::string to_json(const std::vector< const Node * > &seeds)
Emit the reachable subgraph as a minimal JSON document.
Definition inspector.hpp:132
Common base for every node participating in the reactive graph.
Definition node.hpp:136
static Graph & graph() noexcept
Returns the process-wide singleton Graph this node belongs to.
Definition graph.inl:30
void for_each_source(F &&f) const
Iterate the source list (used by a Derivation in pull() to compare each upstream's current version ag...
Definition node.hpp:243
Definition computed.hpp:60
NodeState
Definition node.hpp:102
@ Computing
Definition node.hpp:106
@ Clean
Definition node.hpp:103
@ MaybeDirty
Definition node.hpp:104
@ Dirty
Definition node.hpp:105
NodeKind
Definition node.hpp:87
@ Derivation
Definition node.hpp:89
@ Reaction
Definition node.hpp:90
@ Source
Definition node.hpp:88
std::shared_ptr< FlushTraceFn > & flush_trace_hook_() noexcept
A single event emitted by the Graph during flush.
Definition inspector.hpp:171
A single dependency edge: (upstream source) -> (downstream observer).
Definition node.hpp:113
Node * observer
The downstream node that depends on it.
Definition node.hpp:115
std::uint64_t observed_version
Upstream version observed at the moment this edge was established or last confirmed.
Definition node.hpp:130
Node * source
The upstream node being observed.
Definition node.hpp:114
A single event emitted by the Graph during flush.
Definition inspector.hpp:171
Phase
Definition inspector.hpp:172
@ Recomputed
pull ran; changed = whether value moved
Definition inspector.hpp:177
@ Pull
about to pull node
Definition inspector.hpp:175
long long duration_us
Elapsed wall-clock microseconds between the matching Pull and this Recomputed event.
Definition inspector.hpp:189
bool changed
valid for Phase::Recomputed
Definition inspector.hpp:184
int round
1-based round index
Definition inspector.hpp:183
const Node * node
null for FlushBegin / FlushEnd / Round boundaries
Definition inspector.hpp:182
Phase phase
Definition inspector.hpp:181