use alloc::boxed::Box;
use alloc::collections::VecDeque;
use alloc::rc::{Rc, Weak};
use alloc::vec::Vec;
use core::cell::{Cell, RefCell};
use core::error::Error;
use core::fmt;
use core::panic::Location;
use core::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
use hashbrown::{HashMap, HashSet};
use runite::queue_microtask;
use crate::{
DiagnosticEvent, DiagnosticSubscription, InvalidationCause, InvalidationLevel, NodeId,
ReactorId, trace_targets,
};
type Job = Box<dyn FnOnce() + 'static>;
type DiagnosticCallback = Rc<dyn Fn(DiagnosticEvent)>;
static NEXT_REACTOR_ID: AtomicU64 = AtomicU64::new(1);
thread_local! {
static CURRENT_REACTOR: RefCell<Weak<ReactorInner>> = const { RefCell::new(Weak::new()) };
static ANCHORED_REACTOR: RefCell<Option<Rc<ReactorInner>>> = const { RefCell::new(None) };
static DEFAULT_INSTALL_COUNT: Cell<u32> = const { Cell::new(0) };
static UNTRACKED_DEPTH: Cell<u32> = const { Cell::new(0) };
}
#[cfg(debug_assertions)]
thread_local! {
static RUNNING_REACTOR: Cell<*const ()> = const { Cell::new(core::ptr::null()) };
}
pub fn current() -> Reactor {
Reactor::current()
}
pub fn try_current() -> Option<Reactor> {
Reactor::try_current()
}
pub struct EnterGuard {
previous_default: Weak<ReactorInner>,
previous_anchor: Option<Rc<ReactorInner>>,
}
impl fmt::Debug for EnterGuard {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("EnterGuard").finish_non_exhaustive()
}
}
impl Drop for EnterGuard {
fn drop(&mut self) {
CURRENT_REACTOR.replace(core::mem::take(&mut self.previous_default));
ANCHORED_REACTOR.replace(self.previous_anchor.take());
tracing::debug!(
target: trace_targets::GRAPH,
event = "reactor_exit",
"restored the previous thread default reactor"
);
}
}
pub fn untrack<T>(f: impl FnOnce() -> T) -> T {
UNTRACKED_DEPTH.with(|depth| depth.set(depth.get() + 1));
struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
UNTRACKED_DEPTH.with(|depth| depth.set(depth.get() - 1));
}
}
let _guard = Guard;
f()
}
fn is_untracked() -> bool {
UNTRACKED_DEPTH.with(|depth| depth.get() > 0)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum Mark {
Check,
Dirty,
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(crate) enum State {
Clean,
Check,
Dirty,
}
impl From<Mark> for State {
fn from(mark: Mark) -> Self {
match mark {
Mark::Check => State::Check,
Mark::Dirty => State::Dirty,
}
}
}
impl From<Mark> for InvalidationLevel {
fn from(mark: Mark) -> Self {
match mark {
Mark::Check => Self::Check,
Mark::Dirty => Self::Dirty,
}
}
}
pub(crate) struct DirtyOnUnwind<'a> {
pub(crate) state: &'a Cell<State>,
pub(crate) armed: bool,
}
impl Drop for DirtyOnUnwind<'_> {
fn drop(&mut self) {
if self.armed {
self.state.set(State::Dirty);
}
}
}
pub(crate) trait ObserverHook {
fn mark(&self, mark: Mark, cause: Option<InvalidationCause>);
fn refresh(&self) {}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReactCycleError {
cycle: Vec<NodeId>,
origins: Vec<Option<&'static Location<'static>>>,
}
impl ReactCycleError {
fn new(cycle: Vec<NodeId>, origins: Vec<Option<&'static Location<'static>>>) -> Self {
Self { cycle, origins }
}
pub fn cycle(&self) -> &[NodeId] {
&self.cycle
}
pub fn origins(&self) -> &[Option<&'static Location<'static>>] {
&self.origins
}
}
impl fmt::Display for ReactCycleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "reactive cycle detected: ")?;
for (index, node) in self.cycle.iter().enumerate() {
if index != 0 {
write!(f, " -> ")?;
}
write!(f, "{node}")?;
if let Some(origin) = self.origins.get(index).copied().flatten() {
write!(f, " (created at {origin})")?;
}
}
Ok(())
}
}
impl Error for ReactCycleError {}
#[derive(Clone)]
pub struct Reactor {
inner: Rc<ReactorInner>,
}
impl Reactor {
pub fn new() -> Self {
let reactor = Self {
inner: Rc::new(ReactorInner::new()),
};
tracing::debug!(
target: trace_targets::GRAPH,
event = "reactor_new",
"created reactive reactor"
);
reactor
}
pub fn subscribe_diagnostics(
&self,
callback: impl Fn(DiagnosticEvent) + 'static,
) -> DiagnosticSubscription {
let token = self.inner.next_diagnostic.get();
self.inner.next_diagnostic.set(token.wrapping_add(1).max(1));
self.inner
.diagnostics
.borrow_mut()
.push((token, Rc::new(callback)));
self.inner.diagnostics_active.set(true);
let inner = Rc::clone(&self.inner);
DiagnosticSubscription::new(move || {
inner
.diagnostics
.borrow_mut()
.retain(|(candidate, _)| *candidate != token);
inner
.diagnostics_active
.set(!inner.diagnostics.borrow().is_empty());
})
}
pub fn current() -> Self {
if let Some(reactor) = Self::try_current() {
#[cfg(debug_assertions)]
tracing::trace!(
target: trace_targets::GRAPH,
event = "current_reactor_reuse",
"reusing current thread default reactor"
);
return reactor;
}
let reactor = Self::new();
CURRENT_REACTOR.replace(Rc::downgrade(&reactor.inner));
let installs = DEFAULT_INSTALL_COUNT.with(|count| {
let installs = count.get().wrapping_add(1);
count.set(installs);
installs
});
if installs > 1 {
tracing::warn!(
target: trace_targets::GRAPH,
event = "current_reactor_reinstall",
installs,
"the thread's default reactor expired and a fresh, unrelated one was installed; \
nodes created before and after this point are on separate graphs. Hold the \
reactor alive with Reactor::enter, or use try_current to make the absence an \
error"
);
} else {
tracing::debug!(
target: trace_targets::GRAPH,
event = "current_reactor_install",
"installed current thread default reactor"
);
}
reactor
}
pub fn try_current() -> Option<Self> {
CURRENT_REACTOR
.with(|r| r.borrow().upgrade())
.map(|inner| Self { inner })
}
pub fn enter(&self) -> EnterGuard {
let previous_default = CURRENT_REACTOR.replace(Rc::downgrade(&self.inner));
let previous_anchor = ANCHORED_REACTOR.replace(Some(Rc::clone(&self.inner)));
tracing::debug!(
target: trace_targets::GRAPH,
event = "reactor_enter",
reactor_id = self.inner.id.get(),
"entered reactor as the thread default"
);
EnterGuard {
previous_default,
previous_anchor,
}
}
pub fn run_in_context<T>(&self, observer: NodeId, f: impl FnOnce() -> T) -> T {
let _span = tracing::debug_span!(
target: trace_targets::GRAPH,
"reactor.run_in_context",
observer_id = observer.0
)
.entered();
self.clear_observer_dependencies(observer);
self.inner.stack.borrow_mut().push(observer);
let inserted = self.inner.active_computations.borrow_mut().insert(observer);
debug_assert!(inserted, "observer should not already be active");
#[cfg(debug_assertions)]
let previous_running =
RUNNING_REACTOR.with(|running| running.replace(Rc::as_ptr(&self.inner).cast::<()>()));
struct Guard<'a> {
inner: &'a ReactorInner,
#[cfg(debug_assertions)]
previous_running: *const (),
}
impl Drop for Guard<'_> {
fn drop(&mut self) {
let popped = self.inner.stack.borrow_mut().pop();
debug_assert!(popped.is_some(), "reactor observer stack underflow");
if let Some(node) = popped {
let removed = self.inner.active_computations.borrow_mut().remove(&node);
debug_assert!(removed, "observer should have been active");
}
#[cfg(debug_assertions)]
RUNNING_REACTOR.with(|running| running.set(self.previous_running));
}
}
let _guard = Guard {
inner: &self.inner,
#[cfg(debug_assertions)]
previous_running,
};
f()
}
pub(crate) fn cycle_check(&self, observable: NodeId) -> Result<(), ReactCycleError> {
if self
.inner
.active_computations
.borrow()
.contains(&observable)
{
let stack = self.inner.stack.borrow();
let start = stack
.iter()
.position(|node| *node == observable)
.expect("active computation should appear in observer stack");
let mut cycle = stack[start..].to_vec();
cycle.push(observable);
let origins = cycle.iter().map(|node| self.origin(*node)).collect();
tracing::debug!(
target: trace_targets::GRAPH,
event = "cycle_detected",
observable_id = observable.0,
cycle_len = cycle.len(),
"reactive cycle detected"
);
return Err(ReactCycleError::new(cycle, origins));
}
Ok(())
}
pub(crate) fn assert_no_cycle(&self, observable: NodeId) {
if let Err(e) = self.cycle_check(observable) {
panic!("{e}");
}
}
pub fn try_observe(&self, observable: NodeId) -> Result<(), ReactCycleError> {
self.cycle_check(observable)?;
if is_untracked() {
return Ok(());
}
#[cfg(debug_assertions)]
self.assert_running_reactor();
let current = self.inner.stack.borrow().last().copied();
let Some(observer) = current else {
return Ok(());
};
#[cfg(debug_assertions)]
tracing::trace!(
target: trace_targets::GRAPH,
event = "observe",
observer_id = observer.0,
observable_id = observable.0,
"recording reactive dependency"
);
self.inner
.dependencies
.borrow_mut()
.entry(observer)
.or_default()
.insert(observable, self.version(observable));
let became_observed = {
let mut dependents = self.inner.dependents.borrow_mut();
let observers = dependents.entry(observable).or_default();
let was_unobserved = observers.is_empty();
observers.insert(observer);
was_unobserved
};
if became_observed {
self.note_observation_change(observable, true);
}
Ok(())
}
pub fn observe(&self, observable: NodeId) {
if let Err(e) = self.try_observe(observable) {
panic!("{e}");
}
}
#[track_caller]
pub fn trigger(&self, observable: NodeId) {
self.bump_version(observable);
let write_origin = Location::caller();
let cause = if self.diagnostics_enabled() {
self.origin(observable)
.map(|node_origin| InvalidationCause {
node: observable,
version: self.version(observable),
node_origin,
write_origin,
})
} else {
None
};
if let Some(cause) = cause {
self.inner.emit(DiagnosticEvent::ReactiveWrite {
reactor: self.inner.id,
cause,
});
}
self.mark_dependents(observable, Mark::Dirty, cause);
}
pub(crate) fn mark_dependents(
&self,
observable: NodeId,
mark: Mark,
cause: Option<InvalidationCause>,
) {
let dependents = self
.inner
.dependents
.borrow()
.get(&observable)
.map(|nodes| nodes.iter().copied().collect::<Vec<_>>())
.unwrap_or_default();
#[cfg(debug_assertions)]
tracing::trace!(
target: trace_targets::GRAPH,
event = "mark_dependents",
observable_id = observable.0,
dependent_count = dependents.len(),
?mark,
"marking reactive dependents"
);
for dependent in dependents {
let hook = self
.inner
.observers
.borrow()
.get(&dependent)
.cloned()
.and_then(|weak| weak.upgrade());
if let Some(hook) = hook {
hook.mark(mark, cause);
} else {
self.inner.observers.borrow_mut().remove(&dependent);
}
}
}
pub(crate) fn refresh_node(&self, node: NodeId) {
self.assert_no_cycle(node);
let hook = self
.inner
.observers
.borrow()
.get(&node)
.cloned()
.and_then(|weak| weak.upgrade());
if let Some(hook) = hook {
hook.refresh();
}
}
pub(crate) fn dependencies_changed(&self, observer: NodeId) -> bool {
for (dependency, seen_version) in self.dependencies_of(observer) {
self.refresh_node(dependency);
if self.version(dependency) != seen_version {
return true;
}
}
false
}
pub fn is_observed(&self, node: NodeId) -> bool {
self.inner
.dependents
.borrow()
.get(&node)
.is_some_and(|observers| !observers.is_empty())
}
pub fn dispose(&self, node: NodeId) {
tracing::debug!(
target: trace_targets::GRAPH,
event = "dispose_node",
node_id = node.0,
"disposing reactive node bookkeeping"
);
self.clear_observer_dependencies(node);
let incoming = self
.inner
.dependents
.borrow_mut()
.remove(&node)
.map(|nodes| nodes.into_iter().collect::<Vec<_>>())
.unwrap_or_default();
for observer in incoming {
let mut dependencies = self.inner.dependencies.borrow_mut();
if let Some(observed) = dependencies.get_mut(&observer) {
observed.remove(&node);
if observed.is_empty() {
dependencies.remove(&observer);
}
}
}
self.inner.observers.borrow_mut().remove(&node);
self.inner.meta.borrow_mut().remove(&node);
self.unregister_observation_hooks(node);
}
pub fn schedule(&self, job: impl FnOnce() + 'static) {
self.inner
.pending_jobs
.borrow_mut()
.push_back(Box::new(job));
#[cfg(debug_assertions)]
tracing::trace!(
target: trace_targets::GRAPH,
event = "schedule_job",
pending_jobs = self.inner.pending_jobs.borrow().len(),
"queued reactive job for microtask flush"
);
self.inner.ensure_flush_scheduled();
}
pub fn flush_now(&self) {
Rc::clone(&self.inner).flush_jobs();
}
pub fn external_flush<T>(&self, f: impl FnOnce() -> T) -> T {
self.inner.begin_flush();
struct Guard<'a>(&'a ReactorInner);
impl Drop for Guard<'_> {
fn drop(&mut self) {
self.0.end_flush();
}
}
let _guard = Guard(&self.inner);
f()
}
pub(crate) fn in_flush(&self) -> bool {
self.inner.flush_depth.get() > 0
}
pub(crate) fn begin_flush(&self) {
self.inner.begin_flush();
}
pub(crate) fn end_flush(&self) {
self.inner.end_flush();
}
#[track_caller]
pub(crate) fn allocate_node(&self) -> NodeId {
let raw = self.inner.next_node.get();
self.inner.next_node.set(raw.wrapping_add(1));
let id = NodeId::new(raw);
self.inner.meta.borrow_mut().insert(
id,
NodeMeta {
version: 0,
origin: Location::caller(),
},
);
#[cfg(debug_assertions)]
tracing::trace!(
target: trace_targets::GRAPH,
event = "allocate_node",
node_id = id.0,
"allocated reactive node id"
);
id
}
pub(crate) fn register_observer(&self, id: NodeId, observer: Rc<dyn ObserverHook>) {
self.inner
.observers
.borrow_mut()
.insert(id, Rc::downgrade(&observer));
}
pub(crate) fn unregister_observer(&self, id: NodeId) {
self.inner.observers.borrow_mut().remove(&id);
}
pub(crate) fn bump_version(&self, node: NodeId) {
if let Some(meta) = self.inner.meta.borrow_mut().get_mut(&node) {
meta.version = meta.version.wrapping_add(1);
}
}
pub(crate) fn version(&self, node: NodeId) -> u64 {
self.inner
.meta
.borrow()
.get(&node)
.map(|meta| meta.version)
.unwrap_or(0)
}
pub(crate) fn origin(&self, node: NodeId) -> Option<&'static Location<'static>> {
self.inner.meta.borrow().get(&node).map(|meta| meta.origin)
}
pub(crate) fn dependencies_of(&self, observer: NodeId) -> Vec<(NodeId, u64)> {
self.inner
.dependencies
.borrow()
.get(&observer)
.map(|edges| edges.iter().map(|(id, version)| (*id, *version)).collect())
.unwrap_or_default()
}
pub(crate) fn flush_epoch(&self) -> u64 {
self.inner.flush_epoch.get()
}
pub(crate) fn emit_diagnostic(&self, event: DiagnosticEvent) {
self.inner.emit(event);
}
pub(crate) fn diagnostics_enabled(&self) -> bool {
self.inner.diagnostics_active.get()
}
pub fn id(&self) -> ReactorId {
self.inner.id
}
pub(crate) fn diagnostic_id(&self) -> ReactorId {
self.inner.id
}
#[cfg(debug_assertions)]
fn assert_running_reactor(&self) {
RUNNING_REACTOR.with(|running| {
let running = running.get();
if !running.is_null() && running != Rc::as_ptr(&self.inner).cast::<()>() {
panic!(
"adaptite: a node belonging to one reactor was read inside a computation \
running in a different reactor on the same thread; this dependency cannot \
be tracked and the observer will not re-run when the node changes"
);
}
});
}
fn clear_observer_dependencies(&self, observer: NodeId) {
let observed = self
.inner
.dependencies
.borrow_mut()
.remove(&observer)
.map(|edges| edges.into_keys().collect::<Vec<_>>())
.unwrap_or_default();
let mut unobserved = Vec::new();
for observable in observed {
let mut dependents = self.inner.dependents.borrow_mut();
if let Some(observers) = dependents.get_mut(&observable) {
observers.remove(&observer);
if observers.is_empty() {
dependents.remove(&observable);
unobserved.push(observable);
}
}
}
for observable in unobserved {
self.note_observation_change(observable, false);
}
}
fn note_observation_change(&self, node: NodeId, observed: bool) {
let hooks = self.inner.observation_hooks.borrow().get(&node).cloned();
let Some(hooks) = hooks else {
return;
};
hooks.observed.set(observed);
if hooks.queued.replace(true) {
return;
}
self.schedule(move || {
hooks.queued.set(false);
if hooks.cancelled.get() {
return;
}
let observed = hooks.observed.get();
if hooks.delivered.replace(observed) == observed {
return;
}
if observed {
(hooks.on_watch)();
} else {
(hooks.on_unwatch)();
}
});
}
pub(crate) fn register_observation_hooks(
&self,
node: NodeId,
on_watch: impl Fn() + 'static,
on_unwatch: impl Fn() + 'static,
) {
self.inner.observation_hooks.borrow_mut().insert(
node,
Rc::new(ObservationHooks {
on_watch: Box::new(on_watch),
on_unwatch: Box::new(on_unwatch),
observed: Cell::new(false),
delivered: Cell::new(false),
queued: Cell::new(false),
cancelled: Cell::new(false),
}),
);
}
pub(crate) fn unregister_observation_hooks(&self, node: NodeId) {
if let Some(hooks) = self.inner.observation_hooks.borrow_mut().remove(&node) {
hooks.cancelled.set(true);
}
}
}
struct ObservationHooks {
on_watch: Box<dyn Fn()>,
on_unwatch: Box<dyn Fn()>,
observed: Cell<bool>,
delivered: Cell<bool>,
queued: Cell<bool>,
cancelled: Cell<bool>,
}
impl Default for Reactor {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for Reactor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Reactor")
.field("ptr", &Rc::as_ptr(&self.inner))
.finish()
}
}
struct NodeMeta {
version: u64,
origin: &'static Location<'static>,
}
struct ReactorInner {
id: ReactorId,
next_node: Cell<u64>,
meta: RefCell<HashMap<NodeId, NodeMeta>>,
dependencies: RefCell<HashMap<NodeId, HashMap<NodeId, u64>>>,
dependents: RefCell<HashMap<NodeId, HashSet<NodeId>>>,
observers: RefCell<HashMap<NodeId, Weak<dyn ObserverHook>>>,
stack: RefCell<Vec<NodeId>>,
active_computations: RefCell<HashSet<NodeId>>,
pending_jobs: RefCell<VecDeque<Job>>,
flush_scheduled: Cell<bool>,
flush_epoch: Cell<u64>,
flush_depth: Cell<u32>,
next_diagnostic: Cell<u64>,
diagnostics_active: Cell<bool>,
diagnostics: RefCell<Vec<(u64, DiagnosticCallback)>>,
observation_hooks: RefCell<HashMap<NodeId, Rc<ObservationHooks>>>,
}
impl ReactorInner {
fn new() -> Self {
Self {
id: ReactorId(NEXT_REACTOR_ID.fetch_add(1, AtomicOrdering::Relaxed)),
next_node: Cell::new(1),
meta: RefCell::new(HashMap::new()),
dependencies: RefCell::new(HashMap::new()),
dependents: RefCell::new(HashMap::new()),
observers: RefCell::new(HashMap::new()),
stack: RefCell::new(Vec::new()),
active_computations: RefCell::new(HashSet::new()),
pending_jobs: RefCell::new(VecDeque::new()),
flush_scheduled: Cell::new(false),
flush_epoch: Cell::new(0),
flush_depth: Cell::new(0),
next_diagnostic: Cell::new(1),
diagnostics_active: Cell::new(false),
diagnostics: RefCell::new(Vec::new()),
observation_hooks: RefCell::new(HashMap::new()),
}
}
fn emit(&self, event: DiagnosticEvent) {
if !self.diagnostics_active.get() {
return;
}
let callbacks = self.diagnostics.borrow();
if callbacks.len() == 1 {
let callback = Rc::clone(&callbacks[0].1);
drop(callbacks);
callback(event);
return;
}
let snapshot = callbacks
.iter()
.map(|(_, callback)| Rc::clone(callback))
.collect::<Vec<_>>();
drop(callbacks);
for callback in snapshot {
callback(event);
}
}
fn begin_flush(&self) {
let depth = self.flush_depth.get();
self.flush_depth.set(depth + 1);
if depth > 0 {
return;
}
self.flush_epoch.set(self.flush_epoch.get().wrapping_add(1));
let epoch = self.flush_epoch.get();
#[cfg(debug_assertions)]
tracing::trace!(
target: trace_targets::GRAPH,
event = "begin_external_flush",
flush_epoch = epoch,
"opened an externally driven flush"
);
if self.diagnostics_active.get() {
self.emit(DiagnosticEvent::FlushStarted {
reactor: self.id,
flush_epoch: epoch,
pending_jobs: self.pending_jobs.borrow().len(),
});
}
}
fn end_flush(&self) {
let depth = self.flush_depth.get().saturating_sub(1);
self.flush_depth.set(depth);
if depth > 0 {
return;
}
if self.diagnostics_active.get() {
self.emit(DiagnosticEvent::FlushFinished {
reactor: self.id,
flush_epoch: self.flush_epoch.get(),
remaining_jobs: self.pending_jobs.borrow().len(),
});
}
}
fn ensure_flush_scheduled(self: &Rc<Self>) {
if self.flush_scheduled.replace(true) {
return;
}
#[cfg(debug_assertions)]
tracing::trace!(
target: trace_targets::GRAPH,
event = "schedule_flush",
pending_jobs = self.pending_jobs.borrow().len(),
"scheduling reactive microtask flush"
);
let reactor = Rc::clone(self);
queue_microtask(move || {
reactor.flush_jobs();
});
}
fn flush_jobs(self: Rc<Self>) {
let _span = tracing::debug_span!(
target: trace_targets::GRAPH,
"reactor.flush_jobs"
)
.entered();
self.flush_epoch.set(self.flush_epoch.get().wrapping_add(1));
let epoch = self.flush_epoch.get();
if self.diagnostics_active.get() {
self.emit(DiagnosticEvent::FlushStarted {
reactor: self.id,
flush_epoch: epoch,
pending_jobs: self.pending_jobs.borrow().len(),
});
}
struct FlushGuard {
inner: Rc<ReactorInner>,
epoch: u64,
}
impl Drop for FlushGuard {
fn drop(&mut self) {
self.inner
.flush_depth
.set(self.inner.flush_depth.get().saturating_sub(1));
if self.inner.diagnostics_active.get() {
self.inner.emit(DiagnosticEvent::FlushFinished {
reactor: self.inner.id,
flush_epoch: self.epoch,
remaining_jobs: self.inner.pending_jobs.borrow().len(),
});
}
self.inner.flush_scheduled.set(false);
if !self.inner.pending_jobs.borrow().is_empty() {
self.inner.ensure_flush_scheduled();
}
}
}
self.flush_depth.set(self.flush_depth.get() + 1);
let guard = FlushGuard {
inner: Rc::clone(&self),
epoch,
};
loop {
let job = guard.inner.pending_jobs.borrow_mut().pop_front();
let Some(job) = job else {
break;
};
#[cfg(debug_assertions)]
tracing::trace!(
target: trace_targets::GRAPH,
event = "run_job",
remaining_jobs = guard.inner.pending_jobs.borrow().len(),
"running reactive scheduled job"
);
job();
}
}
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::rc::Rc;
use runite::{queue_macrotask, run};
use super::{Reactor, current};
#[test]
fn current_reactor_is_thread_local_singleton() {
let one = current();
let two = current();
assert!(Rc::ptr_eq(&one.inner, &two.inner));
}
#[test]
fn try_current_reports_absence_instead_of_installing_a_reactor() {
assert!(
super::try_current().is_none(),
"try_current must not install a reactor"
);
assert!(
super::try_current().is_none(),
"and must still report absence after being asked once"
);
let installed = current();
let observed = super::try_current().expect("current installed a default");
assert_eq!(observed.id(), installed.id());
}
#[test]
fn entering_anchors_the_reactor_as_the_thread_default() {
let reactor = Reactor::new();
let expected = reactor.id();
let guard = reactor.enter();
drop(reactor);
assert_eq!(
current().id(),
expected,
"the entered reactor stays current with no other handle alive"
);
drop(guard);
assert!(
super::try_current().is_none(),
"dropping the guard restores the absent default"
);
}
#[test]
fn entering_nests_and_restores_the_previous_default() {
let outer = Reactor::new();
let inner = Reactor::new();
let (outer_id, inner_id) = (outer.id(), inner.id());
let outer_guard = outer.enter();
assert_eq!(current().id(), outer_id);
let inner_guard = inner.enter();
assert_eq!(current().id(), inner_id);
drop(inner_guard);
assert_eq!(
current().id(),
outer_id,
"leaving the inner reactor restores the outer one"
);
drop(outer_guard);
assert!(super::try_current().is_none());
}
#[test]
fn an_expired_default_is_replaced_by_an_unrelated_reactor() {
let first = current().id();
let second = current().id();
assert_ne!(
first, second,
"an unanchored default is replaced once nothing keeps it alive"
);
let held = current();
assert_eq!(current().id(), held.id());
}
#[test]
fn observe_records_dependency_edges_with_versions() {
let reactor = Reactor::new();
let observer = reactor.allocate_node();
let observable = reactor.allocate_node();
reactor.trigger(observable);
reactor.run_in_context(observer, || {
reactor
.try_observe(observable)
.expect("should not detect cycle")
});
assert_eq!(
reactor.dependencies_of(observer),
vec![(observable, reactor.version(observable))]
);
assert_eq!(
reactor.inner.dependents.borrow().get(&observable),
Some(&[observer].into_iter().collect())
);
}
#[test]
fn cycle_detection_panics_with_path_and_origins() {
let reactor = Reactor::new();
let a = reactor.allocate_node();
let b = reactor.allocate_node();
let panic = catch_unwind(AssertUnwindSafe(|| {
reactor.run_in_context(a, || {
reactor.observe(b);
reactor.run_in_context(b, || {
reactor.observe(a);
});
});
}))
.expect_err("cycle should panic");
let Some(cycle_error) = panic.downcast_ref::<String>() else {
panic!("panic should be a string");
};
assert!(
cycle_error.contains("reactive cycle detected"),
"panic should indicate cycle detected"
);
assert!(
cycle_error.contains("1 (created at")
&& cycle_error.contains("-> 2 (created at")
&& cycle_error.contains("reactor.rs"),
"panic should include the cycle path with node origins, got: {cycle_error}"
);
}
#[test]
fn scheduled_jobs_flush_on_runtime_microtask_queue() {
let observed = Rc::new(Cell::new(0usize));
queue_macrotask({
let observed = Rc::clone(&observed);
move || {
let reactor = Reactor::new();
reactor.schedule({
let observed = Rc::clone(&observed);
move || observed.set(1)
});
assert_eq!(observed.get(), 0);
}
});
run();
assert_eq!(observed.get(), 1);
}
#[test]
fn graph_survives_dropping_the_reactor_handle() {
let seen = Rc::new(std::cell::RefCell::new(Vec::new()));
let keep_alive = Rc::new(std::cell::RefCell::new(None::<crate::EffectHandle>));
queue_macrotask({
let seen = Rc::clone(&seen);
let keep_alive = Rc::clone(&keep_alive);
move || {
let reactor = Reactor::new();
let source = crate::signal_in(&reactor, 1usize);
let effect = reactor.effect({
let seen = Rc::clone(&seen);
let source = source.clone();
move || seen.borrow_mut().push(source.get())
});
*keep_alive.borrow_mut() = Some(effect);
drop(reactor);
runite::queue_macrotask(move || {
source.set(2);
});
}
});
run();
assert_eq!(&*seen.borrow(), &[1, 2]);
}
#[test]
fn flush_recovers_after_a_panicking_job() {
let observed = Rc::new(Cell::new(0usize));
queue_macrotask({
let observed = Rc::clone(&observed);
move || {
let reactor = Reactor::new();
reactor.schedule(|| panic!("job panics"));
let result = catch_unwind(AssertUnwindSafe(|| reactor.flush_now()));
assert!(result.is_err(), "flush should propagate the job panic");
reactor.schedule({
let observed = Rc::clone(&observed);
move || observed.set(1)
});
reactor.flush_now();
}
});
run();
assert_eq!(observed.get(), 1);
}
}