nami_core/observe.rs
1//! Optional observability for the reactive graph.
2//!
3//! Every signal that *owns* observable state routes its subscriptions and its
4//! notifications through [`WatcherManager`](crate::watcher::WatcherManager).
5//! Combinators such as `map`, `zip` and `distinct` own no watchers of their own;
6//! they forward to their upstream. That split is exactly the graph an inspector
7//! wants: state owners are nodes, combinators are edges. Instrumenting the
8//! manager therefore observes the whole graph from a handful of call sites.
9//!
10//! # Cost when disabled
11//!
12//! With the `observability` feature off, [`Origin`] is a zero-sized type and
13//! every hook in this module is an empty `const fn`. A signal node is
14//! byte-for-byte the size it was before, and the hook calls vanish. The public
15//! shape of [`Origin`] and of the hooks is identical in both modes, so a crate
16//! never compiles only *because* the feature happens to be enabled somewhere
17//! else in the dependency graph.
18//!
19//! # Cost when enabled
20//!
21//! Each attributed node carries four words (identity, creation site, and the
22//! fat pointer of a type name) and each subscribe/unsubscribe/notify performs
23//! one thread-local lookup. The feature requires `std` for that thread-local
24//! and is intended for development builds.
25
26#[cfg(feature = "observability")]
27pub use enabled::*;
28
29#[cfg(not(feature = "observability"))]
30pub use disabled::*;
31
32#[cfg(feature = "observability")]
33mod enabled {
34 use alloc::rc::Rc;
35 use core::any::type_name;
36 use core::cell::{Cell, RefCell};
37 use core::fmt;
38 use core::panic::Location;
39 use std::thread_local;
40
41 use crate::SignalIdentity;
42
43 /// One state-owning node of the reactive graph.
44 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
45 pub struct SignalNode {
46 identity: SignalIdentity,
47 location: &'static Location<'static>,
48 type_name: &'static str,
49 }
50
51 impl SignalNode {
52 /// Stable identity of this node.
53 #[must_use]
54 pub const fn identity(self) -> SignalIdentity {
55 self.identity
56 }
57
58 /// Source location where the node was created.
59 #[must_use]
60 pub const fn location(self) -> &'static Location<'static> {
61 self.location
62 }
63
64 /// Type name of the value the node holds.
65 #[must_use]
66 pub const fn type_name(self) -> &'static str {
67 self.type_name
68 }
69 }
70
71 /// Provenance attached to a watcher manager.
72 ///
73 /// Unattributed managers — those built through `new`/`default` rather than
74 /// by a state-owning signal — carry no node and are never reported.
75 ///
76 /// The `&'static Location` niche keeps this exactly as wide as
77 /// [`SignalNode`] itself, so the possibility of attribution is free.
78 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
79 pub struct Origin(Option<SignalNode>);
80
81 impl Origin {
82 /// Captures the creation site of a signal node that owns `identity`.
83 ///
84 /// This is `#[track_caller]`, so constructors that are themselves
85 /// `#[track_caller]` report their own caller — the application code that
86 /// actually created the state — rather than a location inside `nami`.
87 #[must_use]
88 #[track_caller]
89 pub fn capture<T: ?Sized>(identity: SignalIdentity) -> Self {
90 Self(Some(SignalNode {
91 identity,
92 location: Location::caller(),
93 type_name: type_name::<T>(),
94 }))
95 }
96
97 /// The node this origin describes, if it is attributed to one.
98 #[must_use]
99 pub const fn node(self) -> Option<SignalNode> {
100 self.0
101 }
102 }
103
104 /// Receives reactive-graph lifecycle and notification events.
105 ///
106 /// An observer never sees its *own* signal traffic: dispatch is suppressed
107 /// while an observer callback is on the stack. Without that, an observer
108 /// that touches any signal would recurse forever.
109 pub trait SignalObserver {
110 /// A state-owning node started tracking watchers.
111 fn on_create(&self, node: SignalNode);
112 /// A watcher was registered on the node.
113 fn on_subscribe(&self, node: SignalNode, subscribers: usize);
114 /// A watcher was unregistered from the node.
115 fn on_unsubscribe(&self, node: SignalNode, subscribers: usize);
116 /// The node notified its watchers.
117 fn on_notify(&self, node: SignalNode, subscribers: usize);
118 /// The node was dropped.
119 fn on_drop(&self, node: SignalNode);
120 }
121
122 thread_local! {
123 static OBSERVER: RefCell<Option<Rc<dyn SignalObserver>>> = const { RefCell::new(None) };
124 static DISPATCHING: Cell<bool> = const { Cell::new(false) };
125 }
126
127 /// Installs an observer for the current thread until the scope is dropped.
128 ///
129 /// The reactive graph is thread-confined by construction — every node is
130 /// built from `Rc`/`RefCell` — so thread-local installation scopes the
131 /// observer to exactly one graph.
132 #[must_use = "the observer is uninstalled when the scope is dropped"]
133 pub struct ObserverScope {
134 previous: Option<Rc<dyn SignalObserver>>,
135 }
136
137 impl fmt::Debug for ObserverScope {
138 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
139 formatter
140 .debug_struct("ObserverScope")
141 .field("restores_previous", &self.previous.is_some())
142 .finish()
143 }
144 }
145
146 impl ObserverScope {
147 /// Installs `observer`, returning a guard that restores the previous one.
148 pub fn install(observer: Rc<dyn SignalObserver>) -> Self {
149 let previous = OBSERVER.with(|slot| slot.borrow_mut().replace(observer));
150 Self { previous }
151 }
152 }
153
154 impl Drop for ObserverScope {
155 fn drop(&mut self) {
156 let previous = self.previous.take();
157 // Restoring a slot that thread teardown has already destroyed is a
158 // no-op, and panicking here would abort — see `dispatch`.
159 let _ = OBSERVER.try_with(|slot| {
160 *slot.borrow_mut() = previous;
161 });
162 }
163 }
164
165 /// Runs `dispatch` against the installed observer, if there is one and we
166 /// are not already inside an observer callback.
167 fn dispatch(origin: Origin, run: impl FnOnce(&dyn SignalObserver, SignalNode)) {
168 let Some(node) = origin.0 else {
169 return;
170 };
171 // A signal can outlive these thread-locals: anything the application
172 // parks in a thread-local of its own is dropped during thread teardown,
173 // by which time an observer registered later has already been
174 // destroyed. There is nothing left to report to, and `with` would
175 // panic — which is fatal rather than catchable, because a panic in a
176 // destructor during teardown aborts the process.
177 let Ok(false) = DISPATCHING.try_with(Cell::get) else {
178 return;
179 };
180 // Clone the handle out before calling, so an observer that installs or
181 // uninstalls a scope cannot invalidate a live borrow.
182 let Ok(Some(observer)) = OBSERVER.try_with(|slot| slot.borrow().clone()) else {
183 return;
184 };
185 DISPATCHING.with(|flag| flag.set(true));
186 run(observer.as_ref(), node);
187 DISPATCHING.with(|flag| flag.set(false));
188 }
189
190 /// Reports that a state-owning node began tracking watchers.
191 pub fn on_create(origin: Origin) {
192 dispatch(origin, |observer, node| observer.on_create(node));
193 }
194
195 /// Reports a watcher registration.
196 pub fn on_subscribe(origin: Origin, subscribers: usize) {
197 dispatch(origin, |observer, node| {
198 observer.on_subscribe(node, subscribers);
199 });
200 }
201
202 /// Reports a watcher cancellation.
203 pub fn on_unsubscribe(origin: Origin, subscribers: usize) {
204 dispatch(origin, |observer, node| {
205 observer.on_unsubscribe(node, subscribers);
206 });
207 }
208
209 /// Reports a notification delivered to `subscribers` watchers.
210 pub fn on_notify(origin: Origin, subscribers: usize) {
211 dispatch(origin, |observer, node| {
212 observer.on_notify(node, subscribers);
213 });
214 }
215
216 /// Reports that a node was dropped.
217 pub fn on_drop(origin: Origin) {
218 dispatch(origin, |observer, node| observer.on_drop(node));
219 }
220}
221
222#[cfg(not(feature = "observability"))]
223mod disabled {
224 use crate::SignalIdentity;
225
226 /// Provenance attached to a watcher manager.
227 ///
228 /// Observability is disabled, so this is a zero-sized type and carries
229 /// nothing.
230 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
231 pub struct Origin;
232
233 impl Origin {
234 /// Captures nothing.
235 #[must_use]
236 pub const fn capture<T: ?Sized>(_identity: SignalIdentity) -> Self {
237 Self
238 }
239 }
240
241 /// Reports nothing.
242 pub const fn on_create(_origin: Origin) {}
243
244 /// Reports nothing.
245 pub const fn on_subscribe(_origin: Origin, _subscribers: usize) {}
246
247 /// Reports nothing.
248 pub const fn on_unsubscribe(_origin: Origin, _subscribers: usize) {}
249
250 /// Reports nothing.
251 pub const fn on_notify(_origin: Origin, _subscribers: usize) {}
252
253 /// Reports nothing.
254 pub const fn on_drop(_origin: Origin) {}
255}
256
257#[cfg(test)]
258mod tests {
259 use super::Origin;
260
261 /// A disabled `Origin` must not make signal nodes bigger. That is the whole
262 /// promise of the feature gate, so assert it rather than trusting it.
263 #[cfg(not(feature = "observability"))]
264 #[test]
265 fn origin_is_zero_sized_when_disabled() {
266 assert_eq!(core::mem::size_of::<Origin>(), 0);
267 }
268
269 /// An attributed origin must cost no more than the node it describes — the
270 /// `&'static Location` niche has to absorb the `Option`, so that an
271 /// unattributed manager pays nothing for the possibility of attribution.
272 #[cfg(feature = "observability")]
273 #[test]
274 fn attributed_origin_costs_no_more_than_its_node() {
275 use super::SignalNode;
276
277 assert_eq!(
278 core::mem::size_of::<Origin>(),
279 core::mem::size_of::<SignalNode>(),
280 "the Option must be niched into the location reference"
281 );
282 // identity + location + type name (a fat pointer).
283 assert_eq!(
284 core::mem::size_of::<SignalNode>(),
285 4 * core::mem::size_of::<usize>()
286 );
287 }
288
289 #[cfg(feature = "observability")]
290 #[test]
291 fn origin_reports_the_constructor_caller() {
292 use crate::SignalIdentity;
293 use alloc::rc::Rc;
294
295 #[track_caller]
296 fn construct(identity: SignalIdentity) -> Origin {
297 Origin::capture::<i32>(identity)
298 }
299
300 let cell = Rc::new(0_i32);
301 let node = construct(SignalIdentity::from_rc(&cell))
302 .node()
303 .expect("captured origin must be attributed");
304 assert_eq!(node.location().file(), file!());
305 assert!(node.type_name().contains("i32"));
306 }
307
308 #[cfg(feature = "observability")]
309 #[test]
310 fn default_origin_is_unattributed() {
311 assert!(Origin::default().node().is_none());
312 }
313}