Skip to main content

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}