Merge origin/main into macos

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Will Temple
2026-05-16 16:38:10 -04:00
66 changed files with 9558 additions and 2326 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",
] }

152
lib/reactivity/README.md Normal file
View File

@@ -0,0 +1,152 @@
# RUIN Reactivity - Fine-Grained Reactor
Automatic thread-stack-based reactivity for RUIN-apps.
RUIN Reactivity provides an implementation of fine-grained reactivity primitives
for dependency tracking and incremental computation. Those primitives are:
- `Cell<T>`: a tracked-state value cell, primitively observable (sometimes
called a "signal" in other reactor implementations).
- `effect`: a primitive observer that runs once, observes its dependencies, and
runs again whenever its dependencies change.
- `Thunk<T>`: a tracked-state recomputable value defined by a closure.
Invalidated if any of its dependencies change. A `Thunk` is both an observer
and an observable.
- `Event<T>`: a push-style source of events of type `T`. Supports subscription
and cancellation of interest in events.
RUIN reactivity requires the RUIN runtime to function, and cannot be used on
threads not managed by the RUIN runtime.
RUIN reactivity does not function across thread boundaries. It tracks
dependencies between entities on the same thread only.
## Examples
### Observe a cell using an effect
```rs
use std::time::Duration;
use ruin_reactivity::{cell, effect};
use ruin_runtime::{main, set_timeout};
#[main]
fn main() {
// Creates an observable value. Calling `.get` from within an observer will create a dependency, and calling `.set`
// will trigger updates to any dependent observers.
let v = cell(5);
// Creates an observer that prints the value of `v` whenever it changes.
// Calling `.leak()` on the effect handle allows it to run for the lifetime of the program without automatically
// disposing when dropped.
effect({
let v = v.clone();
move || {
println!("v is: {}", v.get());
}
})
.leak();
// Queue a future to wait 5 seconds and then update `v`. This will trigger
// the effect to run again and print the new value.
set_timeout(Duration::from_secs(5), {
let v = v.clone();
move || {
v.set(v.get() + 20);
}
});
}
```
### Observe a thunk using an effect
```rs
use std::time::Duration;
use ruin_reactivity::{cell, effect, thunk};
use ruin_runtime::{clear_interval, main, set_interval, set_timeout};
#[main]
fn main() {
// Two primitive observable values.
let x = cell(5);
let y = cell(10);
// A derived observable value that depends on `x` and `y`. The closure will only run when `x` or `y` change, and the
// result will be cached until then.
let z = thunk({
let x = x.clone();
let y = y.clone();
move || {
println!("calculating z...");
x.get() + y.get()
}
});
// The effect observes `z`, so it will run whenever `z` changes. Because `z` depends on `x` and `y`, the effect will
// run whenever `x` or `y` change.
effect({
let z = z.clone();
move || {
println!("z is: {}", z.get());
}
})
.leak();
// Update `x` and `y` every second. This will trigger the effect to run and print the new value of `z`.
let interval = set_interval(Duration::from_secs(1), {
let x = x.clone();
let y = y.clone();
move || {
x.update(|value| *value += 1);
y.update(|value| *value += 2);
}
});
// After 10 seconds, clear the interval to stop updating `x` and `y`. Once the interval is cleared, the queue will
// empty and the program will exit since there are no more pending tasks.
set_timeout(Duration::from_secs(10), move || {
println!("clearing interval...");
clear_interval(&interval);
});
}
```
### Use an event to handle intra-thread messaging
```rs
use std::{cell::Cell, rc::Rc, time::Duration};
use ruin_reactivity::event;
use ruin_runtime::{main, queue_future, set_interval, time::sleep};
#[main]
fn main() {
let my_event = event::<String>();
my_event.subscribe(|message| {
println!("got event with message: {message}");
});
// Emit an event every 250ms with an incrementing count.
let interval = set_interval(Duration::from_millis(250), {
let counter = Rc::new(Cell::new(0));
move || {
let count = counter.get();
my_event.emit(format!("the count is {}", count));
counter.set(count + 1);
}
});
// After 5 seconds, clear the interval to stop emitting events.
queue_future(async move {
sleep(Duration::from_secs(5)).await;
interval.clear();
});
}
```
## License
Licensed under the [MIT License](../../LICENSE.txt).

View File

@@ -0,0 +1,31 @@
use std::time::Duration;
use ruin_reactivity::{cell, effect};
use ruin_runtime::{main, set_timeout};
#[main]
fn main() {
// Creates an observable value. Calling `.get` from within an observer will create a dependency, and calling `.set`
// will trigger updates to any dependent observers.
let v = cell(5);
// Creates an observer that prints the value of `v` whenever it changes.
// Calling `.leak()` on the effect handle allows it to run for the lifetime of the program without automatically
// disposing when dropped.
effect({
let v = v.clone();
move || {
println!("v is: {}", v.get());
}
})
.leak();
// Queue a future to wait 5 seconds and then update `v`. This will trigger
// the effect to run again and print the new value.
set_timeout(Duration::from_secs(5), {
let v = v.clone();
move || {
v.set(v.get() + 20);
}
});
}

View File

@@ -0,0 +1,49 @@
use std::time::Duration;
use ruin_reactivity::{cell, effect, thunk};
use ruin_runtime::{clear_interval, main, set_interval, set_timeout};
#[main]
fn main() {
// Two primitive observable values.
let x = cell(5);
let y = cell(10);
// A derived observable value that depends on `x` and `y`. The closure will only run when `x` or `y` change, and the
// result will be cached until then.
let z = thunk({
let x = x.clone();
let y = y.clone();
move || {
println!("calculating z...");
x.get() + y.get()
}
});
// The effect observes `z`, so it will run whenever `z` changes. Because `z` depends on `x` and `y`, the effect will
// run whenever `x` or `y` change.
effect({
let z = z.clone();
move || {
println!("z is: {}", z.get());
}
})
.leak();
// Update `x` and `y` every second. This will trigger the effect to run and print the new value of `z`.
let interval = set_interval(Duration::from_secs(1), {
let x = x.clone();
let y = y.clone();
move || {
x.update(|value| *value += 1);
y.update(|value| *value += 2);
}
});
// After 10 seconds, clear the interval to stop updating `x` and `y`. Once the interval is cleared, the queue will
// empty and the program will exit since there are no more pending tasks.
set_timeout(Duration::from_secs(10), move || {
println!("clearing interval...");
clear_interval(&interval);
});
}

View File

@@ -0,0 +1,29 @@
use std::{cell::Cell, rc::Rc, time::Duration};
use ruin_reactivity::event;
use ruin_runtime::{main, queue_future, set_interval, time::sleep};
#[main]
fn main() {
let my_event = event::<String>();
my_event.subscribe(|message| {
println!("got event with message: {message}");
});
// Emit an event every 250ms with an incrementing count.
let interval = set_interval(Duration::from_millis(250), {
let counter = Rc::new(Cell::new(0));
move || {
let count = counter.get();
my_event.emit(format!("the count is {}", count));
counter.set(count + 1);
}
});
// After 5 seconds, clear the interval to stop emitting events.
queue_future(async move {
sleep(Duration::from_secs(5)).await;
interval.clear();
});
}

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,10 +1,14 @@
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};
/// Creates an effect in the current thread's default reactor.
///
/// The effect is scheduled immediately and then re-scheduled whenever one of its dependencies
/// changes.
pub fn effect(f: impl Fn() + 'static) -> EffectHandle {
current().effect(f)
}
@@ -16,6 +20,7 @@ pub fn effect_in(reactor: &Reactor, f: impl Fn() + 'static) -> EffectHandle {
/// Disposable handle for a reactive effect.
#[derive(Clone)]
#[must_use = "effects are automatically disposed when dropped, so you must use the handle or explicitly leak it"]
pub struct EffectHandle {
inner: Rc<EffectInner>,
}
@@ -55,6 +60,14 @@ impl EffectHandle {
Self { inner }
}
/// Consumes the effect and leaks it, allowing it to run for the remainder of the program without automatically
/// disposing when dropped. Use this for effects that you want to run for the lifetime of the program. You CANNOT
/// 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) {
core::mem::forget(self);
}
/// Disposes the effect immediately.
pub fn dispose(&self) {
self.inner.dispose();

View File

@@ -1,6 +1,9 @@
use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
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};
@@ -17,11 +20,13 @@ pub fn event_in<T: 'static>(reactor: &Reactor) -> Event<T> {
}
/// Creates a reactive draining subscription for `event` in the current reactor.
#[must_use = "subscriptions created with `on` must be used to stay active"]
pub fn on<T: Clone + 'static>(event: &Event<T>, handler: impl Fn(&T) + 'static) -> Subscription {
current().on(event, handler)
}
/// Creates a reactive draining subscription for `event` associated with `reactor`.
#[must_use = "subscriptions created with `on_in` must be used to stay active"]
pub fn on_in<T: Clone + 'static>(
reactor: &Reactor,
event: &Event<T>,
@@ -49,6 +54,7 @@ impl Reactor {
}
/// Creates a reactive draining subscription for `event`.
#[must_use = "subscriptions created with `on` must be used to stay active"]
pub fn on<T: Clone + 'static>(
&self,
event: &Event<T>,
@@ -103,7 +109,7 @@ impl<T: 'static> Event<T> {
reactor,
id,
next_subscriber: Cell::new(1),
subscribers: RefCell::new(BTreeMap::new()),
subscribers: RefCell::new(Default::default()),
}),
}
}
@@ -204,7 +210,7 @@ struct EventInner<T> {
reactor: Reactor,
id: NodeId,
next_subscriber: Cell<usize>,
subscribers: RefCell<BTreeMap<usize, Rc<SubscriberFn<T>>>>,
subscribers: RefCell<HashMap<usize, Rc<SubscriberFn<T>>>>,
}
struct SubscriptionInner {
@@ -227,11 +233,12 @@ impl SubscriptionInner {
}
}
impl Drop for SubscriptionInner {
fn drop(&mut self) {
self.unsubscribe();
}
}
// TODO: it's actually kind of hard to use events if subcscriptions have to be manually managed.
// impl Drop for SubscriptionInner {
// fn drop(&mut self) {
// self.unsubscribe();
// }
// }
#[cfg(test)]
mod tests {

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`.
@@ -352,11 +360,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>,
}
@@ -365,11 +373,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),
}
@@ -429,7 +437,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() {
@@ -444,7 +452,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),
@@ -457,7 +469,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();
@@ -465,15 +477,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]