use core::cell::{Cell, RefCell};
use crate::{NodeKind, ReactorId};
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct GraphStats {
pub reactor: ReactorId,
pub live_nodes: usize,
pub live_edges: usize,
pub observed_nodes: usize,
pub queued_effects: usize,
pub pending_jobs: usize,
pub flush_depth: u32,
pub flush_epoch: u64,
pub peak_nodes: usize,
pub peak_edges: usize,
pub peak_pending_jobs: usize,
pub nodes_created: u64,
pub nodes_disposed: u64,
pub edges_added: u64,
pub edges_removed: u64,
pub flushes: u64,
live_nodes_by_kind: [usize; NodeKind::COUNT],
}
impl GraphStats {
pub fn live_nodes_of_kind(&self, kind: NodeKind) -> usize {
self.live_nodes_by_kind[kind.index()]
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct FlushStats {
pub root_writes: u32,
pub writes_suppressed: u32,
pub nodes_marked_check: u32,
pub nodes_marked_dirty: u32,
pub max_propagation_depth: u32,
pub effects_queued: u32,
pub effects_coalesced: u32,
pub effects_run: u32,
pub effects_skipped: u32,
pub effects_disposed: u32,
pub effects_pending: u32,
pub computed_verified: u32,
pub computed_recomputed: u32,
pub computed_changed: u32,
pub computed_suppressed: u32,
pub edges_added: u32,
pub edges_removed: u32,
pub jobs_at_start: u32,
pub jobs_at_finish: u32,
}
impl FlushStats {
pub fn is_empty(&self) -> bool {
self.root_writes == 0
&& self.writes_suppressed == 0
&& self.nodes_marked_check == 0
&& self.nodes_marked_dirty == 0
&& self.effects_queued == 0
&& self.effects_coalesced == 0
&& self.effects_run == 0
&& self.effects_skipped == 0
&& self.effects_disposed == 0
&& self.computed_verified == 0
&& self.computed_recomputed == 0
&& self.edges_added == 0
&& self.edges_removed == 0
}
}
#[derive(Default)]
pub(crate) struct FlushAccounting {
pending: RefCell<FlushStats>,
open: RefCell<Vec<FlushStats>>,
}
impl FlushAccounting {
pub(crate) fn record(&self, f: impl FnOnce(&mut FlushStats)) {
let mut open = self.open.borrow_mut();
match open.last_mut() {
Some(stats) => f(stats),
None => f(&mut self.pending.borrow_mut()),
}
}
pub(crate) fn open_flush(&self, jobs_at_start: usize) {
let mut stats = core::mem::take(&mut *self.pending.borrow_mut());
stats.jobs_at_start = saturating_u32(jobs_at_start);
self.open.borrow_mut().push(stats);
}
pub(crate) fn close_flush(
&self,
jobs_at_finish: usize,
effects_pending: usize,
) -> Option<FlushStats> {
let mut stats = self.open.borrow_mut().pop()?;
stats.jobs_at_finish = saturating_u32(jobs_at_finish);
stats.effects_pending = saturating_u32(effects_pending);
Some(stats)
}
pub(crate) fn reset(&self) {
*self.pending.borrow_mut() = FlushStats::default();
self.open.borrow_mut().clear();
}
}
fn saturating_u32(value: usize) -> u32 {
u32::try_from(value).unwrap_or(u32::MAX)
}
#[derive(Default)]
pub(crate) struct GraphCounters {
live_nodes_by_kind: [Cell<usize>; NodeKind::COUNT],
live_edges: Cell<usize>,
queued_effects: Cell<usize>,
peak_nodes: Cell<usize>,
peak_edges: Cell<usize>,
peak_pending_jobs: Cell<usize>,
nodes_created: Cell<u64>,
nodes_disposed: Cell<u64>,
edges_added: Cell<u64>,
edges_removed: Cell<u64>,
flushes: Cell<u64>,
}
impl GraphCounters {
pub(crate) fn node_created(&self, kind: NodeKind, live_nodes: usize) {
add(&self.live_nodes_by_kind[kind.index()], 1);
tick(&self.nodes_created, 1);
raise(&self.peak_nodes, live_nodes);
}
pub(crate) fn node_disposed(&self, kind: NodeKind) {
sub(&self.live_nodes_by_kind[kind.index()], 1);
tick(&self.nodes_disposed, 1);
}
pub(crate) fn edge_added(&self) {
let live = self.live_edges.get() + 1;
self.live_edges.set(live);
tick(&self.edges_added, 1);
raise(&self.peak_edges, live);
}
pub(crate) fn edges_removed(&self, count: usize) {
sub(&self.live_edges, count);
tick(&self.edges_removed, count as u64);
}
pub(crate) fn effect_queued(&self) {
add(&self.queued_effects, 1);
}
pub(crate) fn effect_unqueued(&self) {
sub(&self.queued_effects, 1);
}
pub(crate) fn queued_effects(&self) -> usize {
self.queued_effects.get()
}
pub(crate) fn flush_opened(&self) {
tick(&self.flushes, 1);
}
pub(crate) fn job_queued(&self, pending_jobs: usize) {
raise(&self.peak_pending_jobs, pending_jobs);
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn snapshot(
&self,
reactor: ReactorId,
live_nodes: usize,
observed_nodes: usize,
pending_jobs: usize,
flush_depth: u32,
flush_epoch: u64,
) -> GraphStats {
GraphStats {
reactor,
live_nodes,
live_edges: self.live_edges.get(),
observed_nodes,
queued_effects: self.queued_effects.get(),
pending_jobs,
flush_depth,
flush_epoch,
peak_nodes: self.peak_nodes.get(),
peak_edges: self.peak_edges.get(),
peak_pending_jobs: self.peak_pending_jobs.get(),
nodes_created: self.nodes_created.get(),
nodes_disposed: self.nodes_disposed.get(),
edges_added: self.edges_added.get(),
edges_removed: self.edges_removed.get(),
flushes: self.flushes.get(),
live_nodes_by_kind: core::array::from_fn(|i| self.live_nodes_by_kind[i].get()),
}
}
}
fn add(cell: &Cell<usize>, n: usize) {
cell.set(cell.get().saturating_add(n));
}
fn sub(cell: &Cell<usize>, n: usize) {
cell.set(cell.get().saturating_sub(n));
}
fn tick(cell: &Cell<u64>, n: u64) {
cell.set(cell.get().saturating_add(n));
}
fn raise(cell: &Cell<usize>, value: usize) {
if value > cell.get() {
cell.set(value);
}
}
#[cfg(test)]
mod tests {
use alloc::rc::Rc;
use core::cell::RefCell;
use crate::{EffectRun, NodeKind, Reactor, memo_in, signal_in, source_in};
#[track_caller]
fn assert_edges_consistent(reactor: &Reactor) {
let (outgoing, incoming) = reactor.walk_edge_counts();
let stats = reactor.graph_stats();
assert_eq!(outgoing, incoming, "the two indexes disagree");
assert_eq!(
stats.observed_nodes,
reactor.walk_observed_nodes(),
"the maintained observed-node counter drifted from the graph"
);
assert_eq!(
stats.live_edges, outgoing,
"the maintained edge counter drifted from the graph"
);
assert_eq!(
stats.edges_added - stats.edges_removed,
stats.live_edges as u64,
"cumulative edge counters do not reconcile to the live count"
);
assert_eq!(
stats.nodes_created - stats.nodes_disposed,
stats.live_nodes as u64
);
}
#[test]
fn counters_survive_a_workload_with_churn_and_disposal() {
let reactor = Reactor::new();
assert_edges_consistent(&reactor);
let toggle = signal_in(&reactor, true);
let left = signal_in(&reactor, 1_u32);
let right = signal_in(&reactor, 2_u32);
let chosen = memo_in(&reactor, {
let toggle = toggle.clone();
let left = left.clone();
let right = right.clone();
move || {
if toggle.get() {
left.get()
} else {
right.get()
}
}
});
let seen = Rc::new(RefCell::new(Vec::new()));
let effect = reactor.effect({
let chosen = chosen.clone();
let seen = Rc::clone(&seen);
move || seen.borrow_mut().push(chosen.get())
});
reactor.flush_now();
let stats = reactor.graph_stats();
assert_eq!(stats.live_nodes, 5);
assert_eq!(stats.live_nodes_of_kind(NodeKind::Signal), 3);
assert_eq!(stats.live_nodes_of_kind(NodeKind::Memo), 1);
assert_eq!(stats.live_nodes_of_kind(NodeKind::Effect), 1);
assert_eq!(stats.live_edges, 3);
assert_eq!(stats.observed_nodes, 3);
assert_edges_consistent(&reactor);
toggle.set(false);
reactor.flush_now();
assert_eq!(*seen.borrow(), [1, 2]);
let after = reactor.graph_stats();
assert_eq!(after.live_edges, 3, "one input swapped for another");
assert!(
after.edges_added > stats.edges_added,
"re-running re-records the edge set, and the churn is visible"
);
assert!(after.edges_removed > stats.edges_removed);
assert_edges_consistent(&reactor);
assert!(after.peak_nodes >= after.live_nodes);
assert!(after.peak_edges >= after.live_edges);
effect.dispose();
drop(chosen);
assert_edges_consistent(&reactor);
let disposed = reactor.graph_stats();
assert_eq!(disposed.live_edges, 2, "the memo still reads its inputs");
assert_eq!(disposed.live_nodes_of_kind(NodeKind::Memo), 1);
assert_eq!(
disposed.live_nodes_of_kind(NodeKind::Effect),
0,
"the effect's own node leaves the graph immediately; what outlives it is the Rust \
value holding its closure, and that is the distinction a leak report needs"
);
drop(effect);
assert_edges_consistent(&reactor);
let torn_down = reactor.graph_stats();
assert_eq!(torn_down.live_edges, 0);
assert_eq!(torn_down.observed_nodes, 0);
assert_eq!(torn_down.live_nodes, 3, "the three signals are still held");
assert_eq!(torn_down.live_nodes_of_kind(NodeKind::Memo), 0);
assert_eq!(torn_down.live_nodes_of_kind(NodeKind::Effect), 0);
assert_eq!(torn_down.peak_nodes, 5, "but the peak remembers");
}
#[test]
fn queued_effects_follows_the_pending_run_latch() {
let reactor = Reactor::new();
let value = signal_in(&reactor, 0_u32);
let lane = Rc::new(RefCell::new(Vec::<EffectRun>::new()));
let effect = reactor.effect_with(
{
let lane = Rc::clone(&lane);
move |ready| lane.borrow_mut().push(ready)
},
{
let value = value.clone();
move || {
let _ = value.get();
}
},
);
assert_eq!(reactor.graph_stats().queued_effects, 1, "the initial run");
value.set(1);
value.set(2);
assert_eq!(reactor.graph_stats().queued_effects, 1);
reactor.external_flush(|| {
for run in core::mem::take(&mut *lane.borrow_mut()) {
run.run();
}
});
assert_eq!(reactor.graph_stats().queued_effects, 0);
value.set(3);
assert_eq!(reactor.graph_stats().queued_effects, 1);
lane.borrow_mut().clear();
assert_eq!(reactor.graph_stats().queued_effects, 0);
effect.dispose();
}
#[test]
fn dropping_a_latched_effect_releases_the_queued_gauge() {
let reactor = Reactor::new();
let baseline = reactor.graph_stats().queued_effects;
for _ in 0..100 {
let _effect = reactor.effect(|| {});
}
reactor.flush_now();
let stats = reactor.graph_stats();
assert_eq!(
stats.queued_effects, baseline,
"an effect dropped while latched must not leave a run pending forever"
);
assert_eq!(stats.live_nodes, 0, "and its node must be gone too");
}
#[test]
fn disposal_unhooks_the_node_even_when_a_cleanup_panics() {
use std::panic::{AssertUnwindSafe, catch_unwind};
let reactor = Reactor::new();
let value = signal_in(&reactor, 0_u32);
let effect = reactor.effect({
let value = value.clone();
move || {
let _ = value.get();
crate::on_cleanup(|| panic!("cleanup failed"));
}
});
reactor.flush_now();
assert_eq!(reactor.graph_stats().live_nodes, 2);
let result = catch_unwind(AssertUnwindSafe(|| effect.dispose()));
assert!(
result.is_err(),
"the cleanup panic still reaches the caller"
);
let stats = reactor.graph_stats();
assert_eq!(
stats.live_nodes_of_kind(NodeKind::Effect),
0,
"the effect's node must leave the graph even though teardown failed"
);
assert_eq!(stats.live_edges, 0, "and so must the edges it recorded");
assert_eq!(reactor.observer_count(value.id()), 0);
}
#[test]
fn a_settled_graph_reports_no_pending_work() {
let reactor = Reactor::new();
let value = signal_in(&reactor, 0_u32);
let effect = reactor.effect({
let value = value.clone();
move || {
let _ = value.get();
}
});
reactor.flush_now();
let idle = reactor.graph_stats();
assert_eq!(idle.queued_effects, 0);
assert_eq!(idle.pending_jobs, 0);
assert_eq!(idle.flush_depth, 0);
let flushes_before = idle.flushes;
value.set(0);
reactor.flush_now();
assert_eq!(
reactor.graph_stats().flushes,
flushes_before,
"an equal `set` is suppressed at the signal, before the graph hears about it"
);
value.set(1);
reactor.flush_now();
let after = reactor.graph_stats();
assert_eq!(after.flushes, flushes_before + 1);
assert_eq!(after.queued_effects, 0);
assert_eq!(after.pending_jobs, 0);
effect.dispose();
}
#[test]
fn an_unobserved_source_is_counted_as_a_node_but_not_as_an_edge() {
let reactor = Reactor::new();
let node = source_in(&reactor);
let stats = reactor.graph_stats();
assert_eq!(stats.live_nodes, 1);
assert_eq!(stats.live_nodes_of_kind(NodeKind::Source), 1);
assert_eq!(stats.live_edges, 0);
assert_eq!(stats.observed_nodes, 0);
drop(node);
let after = reactor.graph_stats();
assert_eq!(after.live_nodes, 0);
assert_eq!(after.nodes_disposed, 1);
}
}