Lots of owork on a calculator example, transparency, etc.

This commit is contained in:
2026-05-16 16:18:55 -04:00
parent e2c2563b7e
commit 6dcc2d0d00
40 changed files with 5783 additions and 125 deletions

View File

@@ -5,7 +5,12 @@ edition = "2024"
[dependencies]
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
tracing = { version = "0.1", default-features = false, features = ["std"] }
tracing = { version = "0.1", default-features = false }
hashbrown = "0.17"
[dev-dependencies]
tracing-subscriber = { version = "0.3", default-features = false, features = ["env-filter", "fmt", "std"] }
tracing-subscriber = { version = "0.3", default-features = false, features = [
"env-filter",
"fmt",
"std",
] }

View File

@@ -1,5 +1,5 @@
use std::cell::RefCell;
use std::rc::Rc;
use alloc::rc::Rc;
use core::cell::RefCell;
use crate::{NodeId, Reactor, current, trace_targets};
@@ -114,7 +114,7 @@ impl<T: PartialEq + 'static> Cell<T> {
return None;
}
let previous = std::mem::replace(&mut *current, value);
let previous = core::mem::replace(&mut *current, value);
drop(current);
tracing::debug!(
target: trace_targets::CELL,

View File

@@ -1,5 +1,6 @@
use std::cell::{Cell, RefCell};
use std::rc::{Rc, Weak};
use alloc::boxed::Box;
use alloc::rc::{Rc, Weak};
use core::cell::{Cell, RefCell};
use crate::reactor::ObserverHook;
use crate::{NodeId, Reactor, current, trace_targets};
@@ -64,7 +65,7 @@ impl EffectHandle {
/// recover an EffectHandle after calling this method, so be sure to call it on an EffectHandle that you don't need
/// to later dispose of.
pub fn leak(self) {
std::mem::forget(self);
core::mem::forget(self);
}
/// Disposes the effect immediately.

View File

@@ -1,6 +1,9 @@
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use alloc::boxed::Box;
use alloc::rc::Rc;
use alloc::vec::Vec;
use core::cell::{Cell, RefCell};
use hashbrown::HashMap;
use crate::{NodeId, Reactor, current, trace_targets};

View File

@@ -4,6 +4,11 @@
//! single-threaded and designed to live on a runtime-managed thread, while async work feeds it
//! from the edges by updating state or emitting events.
#![feature(thread_local)]
#![cfg_attr(not(test), no_std)]
extern crate alloc;
pub(crate) mod trace_targets {
pub const GRAPH: &str = "ruin_reactivity::graph";
pub const CELL: &str = "ruin_reactivity::cell";
@@ -18,6 +23,7 @@ mod effect;
mod event;
mod id;
mod reactor;
mod source;
mod thunk;
pub use cell::{Cell, cell, cell_in};
@@ -25,4 +31,5 @@ pub use effect::{EffectHandle, effect, effect_in};
pub use event::{Event, Subscription, event, event_in, on, on_in};
pub use id::NodeId;
pub use reactor::{ReactCycleError, Reactor, current};
pub use source::{Source, source, source_in};
pub use thunk::{Memo, Thunk, memo, memo_by, memo_by_in, memo_in, thunk, thunk_in};

View File

@@ -1,9 +1,12 @@
use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::error::Error;
use std::fmt;
use std::panic::panic_any;
use std::rc::{Rc, Weak};
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 hashbrown::{HashMap, HashSet};
use ruin_runtime::queue_microtask;
@@ -11,9 +14,8 @@ use crate::{NodeId, trace_targets};
type Job = Box<dyn FnOnce() + 'static>;
thread_local! {
static CURRENT_REACTOR: RefCell<Weak<ReactorInner>> = const { RefCell::new(Weak::new()) };
}
#[thread_local]
static CURRENT_REACTOR: RefCell<Weak<ReactorInner>> = const { RefCell::new(Weak::new()) };
/// Returns the current thread's default reactor.
///
@@ -28,7 +30,7 @@ pub(crate) trait ObserverHook {
fn notify(&self);
}
/// Panic payload emitted when the reactor detects a dependency cycle.
/// Error type for cycles detected in the reactive graph. Contains the path of nodes that form the cycle.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReactCycleError {
cycle: Vec<NodeId>,
@@ -85,26 +87,24 @@ impl Reactor {
/// Returns the current thread's default reactor.
pub fn current() -> Self {
CURRENT_REACTOR.with(|slot| {
if let Some(inner) = slot.borrow().upgrade() {
#[cfg(debug_assertions)]
tracing::trace!(
target: trace_targets::GRAPH,
event = "current_reactor_reuse",
"reusing current thread default reactor"
);
return Self { inner };
}
let reactor = Self::new();
*slot.borrow_mut() = Rc::downgrade(&reactor.inner);
tracing::debug!(
if let Some(inner) = CURRENT_REACTOR.borrow().upgrade() {
#[cfg(debug_assertions)]
tracing::trace!(
target: trace_targets::GRAPH,
event = "current_reactor_install",
"installed current thread default reactor"
event = "current_reactor_reuse",
"reusing current thread default reactor"
);
reactor
})
return Self { inner };
}
let reactor = Self::new();
*CURRENT_REACTOR.borrow_mut() = Rc::downgrade(&reactor.inner);
tracing::debug!(
target: trace_targets::GRAPH,
event = "current_reactor_install",
"installed current thread default reactor"
);
reactor
}
/// Runs `f` in the dependency-tracking scope of `observer`.
@@ -143,13 +143,9 @@ impl Reactor {
f()
}
/// Records that the currently executing observer depends on `observable`.
///
/// # Panics
///
/// Panics with [`ReactCycleError`] if `observable` is already being computed in the current
/// dependency stack.
pub fn observe(&self, observable: NodeId) {
/// Attempts to record a dependency on `observable` for the currently running observer, returning an
/// error if doing so would create a dependency cycle.
pub fn try_observe(&self, observable: NodeId) -> Result<(), ReactCycleError> {
if self
.inner
.active_computations
@@ -170,12 +166,12 @@ impl Reactor {
cycle_len = cycle.len(),
"reactive cycle detected"
);
panic_any(ReactCycleError::new(cycle));
return Err(ReactCycleError::new(cycle));
}
let current = self.inner.stack.borrow().last().copied();
let Some(observer) = current else {
return;
return Ok(());
};
#[cfg(debug_assertions)]
@@ -199,6 +195,18 @@ impl Reactor {
.entry(observable)
.or_default()
.insert(observer);
Ok(())
}
/// Records a dependency on `observable` for the currently running observer.
///
/// # Panics
///
/// Panics if recording the dependency would create a cycle in the reactive graph.
pub fn observe(&self, observable: NodeId) {
if let Err(e) = self.try_observe(observable) {
panic!("reactive cycle detected: {e}");
}
}
/// Notifies dependents of `observable`.
@@ -344,11 +352,11 @@ impl fmt::Debug for Reactor {
struct ReactorInner {
next_node: Cell<u64>,
dependencies: RefCell<BTreeMap<NodeId, BTreeSet<NodeId>>>,
dependents: RefCell<BTreeMap<NodeId, BTreeSet<NodeId>>>,
observers: RefCell<BTreeMap<NodeId, Weak<dyn ObserverHook>>>,
dependencies: RefCell<HashMap<NodeId, HashSet<NodeId>>>,
dependents: RefCell<HashMap<NodeId, HashSet<NodeId>>>,
observers: RefCell<HashMap<NodeId, Weak<dyn ObserverHook>>>,
stack: RefCell<Vec<NodeId>>,
active_computations: RefCell<BTreeSet<NodeId>>,
active_computations: RefCell<HashSet<NodeId>>,
pending_jobs: RefCell<VecDeque<Job>>,
flush_scheduled: Cell<bool>,
}
@@ -357,11 +365,11 @@ impl ReactorInner {
fn new() -> Self {
Self {
next_node: Cell::new(1),
dependencies: RefCell::new(BTreeMap::new()),
dependents: RefCell::new(BTreeMap::new()),
observers: RefCell::new(BTreeMap::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(BTreeSet::new()),
active_computations: RefCell::new(HashSet::new()),
pending_jobs: RefCell::new(VecDeque::new()),
flush_scheduled: Cell::new(false),
}
@@ -421,7 +429,7 @@ mod tests {
use ruin_runtime::{queue_task, run};
use super::{ReactCycleError, Reactor, current};
use super::{Reactor, current};
#[test]
fn current_reactor_is_thread_local_singleton() {
@@ -436,7 +444,11 @@ mod tests {
let observer = reactor.allocate_node();
let observable = reactor.allocate_node();
reactor.run_in_context(observer, || reactor.observe(observable));
reactor.run_in_context(observer, || {
reactor
.try_observe(observable)
.expect("should not detect cycle")
});
assert_eq!(
reactor.inner.dependencies.borrow().get(&observer),
@@ -449,7 +461,7 @@ mod tests {
}
#[test]
fn cycle_detection_panics_with_structured_error() {
fn cycle_detection_panics_with_expected() {
let reactor = Reactor::new();
let a = reactor.allocate_node();
let b = reactor.allocate_node();
@@ -457,15 +469,26 @@ mod tests {
let panic = catch_unwind(AssertUnwindSafe(|| {
reactor.run_in_context(a, || {
reactor.observe(b);
reactor.run_in_context(b, || reactor.observe(a));
reactor.run_in_context(b, || {
reactor.observe(a);
});
});
}))
.expect_err("cycle should panic");
let error = panic
.downcast::<ReactCycleError>()
.expect("panic payload should be ReactCycleError");
assert_eq!(error.cycle(), &[a, b, a]);
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 -> 2 -> 1"),
"panic should include cycle path"
);
}
#[test]

View File

@@ -0,0 +1,70 @@
use alloc::rc::Rc;
use crate::{NodeId, Reactor, current, trace_targets};
/// Creates a low-level reactive source node in the current reactor.
pub fn source() -> Source {
current().source()
}
/// Creates a low-level reactive source node associated with `reactor`.
pub fn source_in(reactor: &Reactor) -> Source {
reactor.source()
}
/// Low-level observable source node.
///
/// `Source` is useful for advanced data structures that want precise control over when reads
/// observe and writes trigger invalidation without storing their state in a [`crate::Cell`].
#[derive(Clone)]
pub struct Source {
inner: Rc<SourceInner>,
}
impl Reactor {
/// Creates a low-level source node associated with this reactor.
pub fn source(&self) -> Source {
Source::new(self.clone())
}
}
impl Source {
fn new(reactor: Reactor) -> Self {
let id = reactor.allocate_node();
tracing::debug!(
target: trace_targets::GRAPH,
event = "create_source",
node_id = id.0,
"created low-level reactive source"
);
Self {
inner: Rc::new(SourceInner { reactor, id }),
}
}
/// Records a dependency on this source for the currently running observer.
pub fn observe(&self) {
self.inner.reactor.observe(self.inner.id);
}
/// Triggers this source's dependents.
pub fn trigger(&self) {
self.inner.reactor.trigger(self.inner.id);
}
/// Returns the source node id.
pub fn id(&self) -> NodeId {
self.inner.id
}
}
struct SourceInner {
reactor: Reactor,
id: NodeId,
}
impl Drop for SourceInner {
fn drop(&mut self) {
self.reactor.dispose(self.id);
}
}

View File

@@ -1,5 +1,6 @@
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use alloc::boxed::Box;
use alloc::rc::Rc;
use core::cell::{Cell, RefCell};
use crate::reactor::ObserverHook;
use crate::{NodeId, Reactor, current, trace_targets};
@@ -428,9 +429,13 @@ mod tests {
.expect_err("mutually recursive thunks should panic");
let error = panic
.downcast::<crate::ReactCycleError>()
.expect("panic payload should be ReactCycleError");
assert_eq!(error.cycle().len(), 3);
.downcast_ref::<String>()
.expect("panic should be a string");
assert!(
error.contains("reactive cycle detected"),
"panic should indicate cycle detected"
);
}
#[test]