Skip to main content

graphrefly/
dispatcher.rs

1//! The dispatcher — first-class invoke funnel + pool host (D20 / D21).
2//!
3//! **F-SYNC-CORE:** [`Dispatcher::invoke`] is synchronous and returns `()`. The
4//! wave-protocol core never blocks on async; async lives only in pools
5//! (LocalAsync, a later slice) and the wire bridge.
6//!
7//! **F-DISPATCH-ALL:** every node fn goes through the dispatcher — no inline-fn
8//! bypass. A fn is registered into a pool and addressed by a pure-data [`Handle`]
9//! `(pool_id, handle_id)` (D7).
10//!
11//! Slice scope: LocalSync + LocalAsync pools (D20). The async pool's `invoke` is
12//! ALSO sync void (R-sync-core) — "async" is a node-kind LABEL, not a different
13//! call mechanism: an async fn kicks off work and emits LATER via a stashed
14//! [`crate::ctx::DeferredCtx`] (the Rust analogue of TS capturing `ctx`). The
15//! grouped `DispatcherOpts` (DR-6) and the opt-in profile recorder (D39) land in
16//! later slices.
17
18use std::cell::RefCell;
19use std::collections::HashMap;
20use std::fmt;
21use std::rc::Rc;
22#[cfg(feature = "tokio-worker")]
23use std::sync::Arc;
24use std::time::Instant;
25
26use crate::async_driver::LocalAsyncDriver;
27use crate::ctx::Ctx;
28pub use crate::protocol::Handle;
29
30/// A node fn: `(&Ctx) -> ()` (R-fn-contract / D8). Single-thread (D22) ⇒ no
31/// `Send` bound. Stored as `Rc` so [`Dispatcher::invoke`] can clone it out of the
32/// pool and **release the pool borrow before calling it** — a fn that re-drives a
33/// downstream node (nested `invoke`) must not find the pool already borrowed.
34pub type NodeFn = Rc<dyn Fn(&Ctx)>;
35
36/// Pool kind label. LocalSync + LocalAsync ship in 1.0 (D20). The kind drives the
37/// NODE'S behavior (async-paused buffering / per-invocation ctx snapshot — see
38/// [`crate::node`]); the dispatcher invoke is sync void either way (R-sync-core).
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum PoolKind {
41    /// LocalSync — the fn runs to completion synchronously.
42    #[default]
43    Sync,
44    /// LocalAsync — the fn kicks off async work and emits later via a stashed
45    /// [`crate::ctx::DeferredCtx`] (the emit serializes back onto the single thread).
46    Async,
47}
48
49/// One slotmap entry: a fn (`None` once freed) + a generation bumped on each free,
50/// so a recycled slot is distinguishable from a stale handle (B32).
51struct Slot {
52    f: Option<NodeFn>,
53    generation: u32,
54}
55
56/// A dispatch pool: a **slotmap** of fns addressed by `(handle_id, generation)`.
57///
58/// B32: a node's fn is freed (slot recycled) when the node drops, so the pool no
59/// longer holds a dropped node's fn — and thus its captured upstream handles —
60/// alive for the whole process (the no-GC leak; `Node: Clone` made capturing
61/// handles-into-fns idiomatic). Without this, every registered fn leaked, pinning
62/// its captured `Core`s forever.
63struct Pool {
64    kind: PoolKind,
65    slots: Vec<Slot>,
66    /// Recycled (freed) slot ids, reused on the next register.
67    free: Vec<u32>,
68}
69
70impl Pool {
71    fn new(kind: PoolKind) -> Self {
72        Self {
73            kind,
74            slots: Vec::new(),
75            free: Vec::new(),
76        }
77    }
78
79    /// Register a fn, returning `(handle_id, generation)`. Reuses a freed slot if one
80    /// is available (keeping the slot vec bounded under churn).
81    fn register(&mut self, f: NodeFn) -> (u32, u32) {
82        if let Some(id) = self.free.pop() {
83            let slot = &mut self.slots[id as usize];
84            slot.f = Some(f);
85            (id, slot.generation)
86        } else {
87            let id = self.slots.len() as u32;
88            self.slots.push(Slot {
89                f: Some(f),
90                generation: 0,
91            });
92            (id, 0)
93        }
94    }
95
96    /// Free a slot (drop its fn) + bump its generation + recycle it. A stale
97    /// unregister (generation mismatch, or already free) is a no-op.
98    fn unregister(&mut self, id: u32, generation: u32) {
99        if let Some(slot) = self.slots.get_mut(id as usize) {
100            if slot.generation == generation && slot.f.is_some() {
101                slot.f = None;
102                slot.generation = slot.generation.wrapping_add(1);
103                self.free.push(id);
104            }
105        }
106    }
107
108    /// Clone out the fn for a handle, iff the generation matches and the slot is live.
109    /// A stale handle (mismatched generation / freed slot) yields `None`.
110    fn get(&self, id: u32, generation: u32) -> Option<NodeFn> {
111        self.slots
112            .get(id as usize)
113            .filter(|s| s.generation == generation)
114            .and_then(|s| s.f.clone())
115    }
116}
117
118struct DispatcherInner {
119    /// 1.0 ships LocalSync + LocalAsync (D20), indexed by `pool_id`:
120    /// `pools[SYNC_POOL_ID]` = sync, `pools[ASYNC_POOL_ID]` = async. The trait stays
121    /// pluggable for WorkerPool/RemotePool (D20) — a `Vec` so a new pool just appends.
122    pools: Vec<Pool>,
123    /// Opt-in profile recorder (D39 / R-profile). Default off = one boolean branch
124    /// per invoke; counters live on the dispatcher, not on the thin node.
125    recording: bool,
126    stats: HashMap<Handle, ProfileStat>,
127    local_async_driver: Option<Rc<dyn LocalAsyncDriver>>,
128    #[cfg(feature = "tokio-worker")]
129    worker_backend: Option<WorkerBackend>,
130}
131
132/// First-class dispatcher (D21), cloneable handle over shared inner state. A graph
133/// binds one; the default is a thread-local singleton (D26).
134#[derive(Clone)]
135pub struct Dispatcher(Rc<RefCell<DispatcherInner>>);
136
137impl fmt::Debug for Dispatcher {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        f.debug_struct("Dispatcher").finish_non_exhaustive()
140    }
141}
142
143/// Per-handle accumulated profile counters (D39).
144#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
145pub struct ProfileStat {
146    /// `invokes` field for invokes.
147    pub invokes: u64,
148    /// `total_duration_ns` field for total duration ns.
149    pub total_duration_ns: u128,
150    /// `last_duration_ns` field for last duration ns.
151    pub last_duration_ns: u128,
152}
153
154#[cfg(feature = "tokio-worker")]
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub(crate) enum WorkerSubmitError {
157    MissingBackend,
158    MissingRuntime,
159}
160
161#[cfg(feature = "tokio-worker")]
162#[derive(Debug, Clone, Copy, Default)]
163struct WorkerBackend;
164
165#[cfg(feature = "tokio-worker")]
166type WorkerTask<R> = Box<dyn FnOnce() -> Result<R, String> + Send + 'static>;
167
168#[cfg(feature = "tokio-worker")]
169pub(crate) struct WorkerJob<R> {
170    handle: tokio::runtime::Handle,
171    task: WorkerTask<R>,
172}
173
174#[cfg(feature = "tokio-worker")]
175impl<R: Send + 'static> WorkerJob<R> {
176    pub(crate) fn spawn(self) -> tokio::task::JoinHandle<Result<R, String>> {
177        let task = self.task;
178        self.handle.spawn_blocking(task)
179    }
180}
181
182#[cfg(feature = "tokio-worker")]
183impl WorkerBackend {
184    fn submit<I, R, E, C>(
185        self,
186        input: I,
187        compute: Arc<C>,
188    ) -> Result<WorkerJob<R>, WorkerSubmitError>
189    where
190        I: Send + 'static,
191        R: Send + 'static,
192        E: fmt::Display + Send + 'static,
193        C: Fn(I) -> Result<R, E> + Send + Sync + 'static,
194    {
195        let handle =
196            tokio::runtime::Handle::try_current().map_err(|_| WorkerSubmitError::MissingRuntime)?;
197        Ok(WorkerJob {
198            handle,
199            task: Box::new(move || compute(input).map_err(|error| error.to_string())),
200        })
201    }
202}
203
204/// The LocalSync pool id (D20).
205pub const SYNC_POOL_ID: u32 = 0;
206/// The LocalAsync pool id (D20).
207pub const ASYNC_POOL_ID: u32 = 1;
208
209impl Dispatcher {
210    /// Creates or computes `new`.
211    pub fn new() -> Self {
212        Dispatcher(Rc::new(RefCell::new(DispatcherInner {
213            // index order MUST match SYNC_POOL_ID / ASYNC_POOL_ID.
214            pools: vec![Pool::new(PoolKind::Sync), Pool::new(PoolKind::Async)],
215            recording: false,
216            stats: HashMap::new(),
217            local_async_driver: None,
218            #[cfg(feature = "tokio-worker")]
219            worker_backend: Some(WorkerBackend),
220        })))
221    }
222
223    /// Turn the profile recorder on/off (D39). Off = near-zero invoke overhead.
224    pub fn set_recording(&self, on: bool) {
225        self.0.borrow_mut().recording = on;
226    }
227
228    /// Read one handle's accumulated counters.
229    pub fn stat_for(&self, handle: Handle) -> Option<ProfileStat> {
230        self.0.borrow().stats.get(&handle).copied()
231    }
232
233    /// Install or clear the graph-local async/time source driver (D111).
234    pub fn set_local_async_driver(&self, driver: Option<Rc<dyn LocalAsyncDriver>>) {
235        self.0.borrow_mut().local_async_driver = driver;
236    }
237
238    /// Read the installed local async/time source driver, if any.
239    pub fn local_async_driver(&self) -> Option<Rc<dyn LocalAsyncDriver>> {
240        self.0.borrow().local_async_driver.clone()
241    }
242
243    #[cfg(feature = "tokio-worker")]
244    pub(crate) fn submit_worker<I, R, E, C>(
245        &self,
246        input: I,
247        compute: Arc<C>,
248    ) -> Result<WorkerJob<R>, WorkerSubmitError>
249    where
250        I: Send + 'static,
251        R: Send + 'static,
252        E: fmt::Display + Send + 'static,
253        C: Fn(I) -> Result<R, E> + Send + Sync + 'static,
254    {
255        let backend = self
256            .0
257            .borrow()
258            .worker_backend
259            .ok_or(WorkerSubmitError::MissingBackend)?;
260        backend.submit(input, compute)
261    }
262
263    #[cfg(all(test, feature = "tokio-worker"))]
264    pub(crate) fn set_worker_backend_for_test(&self, installed: bool) {
265        self.0.borrow_mut().worker_backend = installed.then_some(WorkerBackend);
266    }
267
268    /// Register a fn in the sync pool, returning its [`Handle`] (R-dispatch-all).
269    pub fn register(&self, f: NodeFn) -> Handle {
270        self.register_in(SYNC_POOL_ID, f)
271    }
272
273    /// Register a fn in the LocalAsync pool (D20). The invoke is still sync void
274    /// (R-sync-core) — the node's async-pool kind drives the deferred-emit / pause
275    /// buffering behavior, not the call mechanism.
276    pub fn register_async(&self, f: NodeFn) -> Handle {
277        self.register_in(ASYNC_POOL_ID, f)
278    }
279
280    fn register_in(&self, pool_id: u32, f: NodeFn) -> Handle {
281        let (handle_id, generation) = self.0.borrow_mut().pools[pool_id as usize].register(f);
282        Handle {
283            pool_id,
284            handle_id,
285            generation,
286        }
287    }
288
289    /// The kind of a pool by id — the node reads this to decide async-paused
290    /// buffering + per-invocation ctx snapshotting (R-pause-modes / R-async-paused).
291    pub fn pool_kind(&self, pool_id: u32) -> PoolKind {
292        self.0.borrow().pools[pool_id as usize].kind
293    }
294
295    /// Free a node's fn slot (B32) — called from `NodeInner::Drop`. The pool stops
296    /// holding a dropped node's fn (and thus its captured upstream `Core`s) alive for
297    /// the whole process. Idempotent / generation-checked: a stale unregister is a no-op.
298    pub fn unregister(&self, handle: Handle) {
299        let mut inner = self.0.borrow_mut();
300        inner.pools[handle.pool_id as usize].unregister(handle.handle_id, handle.generation);
301        inner.stats.remove(&handle);
302    }
303
304    /// Uniform sync-void invoke (R-sync-core / R-dispatch-all). Clones the fn out
305    /// and drops the pool borrow before calling, so a nested downstream invoke
306    /// triggered from inside the fn does not double-borrow the pool.
307    pub fn invoke(&self, handle: Handle, ctx: &Ctx) {
308        let (f, recording) = {
309            let inner = self.0.borrow();
310            (
311                inner.pools[handle.pool_id as usize].get(handle.handle_id, handle.generation),
312                inner.recording,
313            )
314        };
315        // A live node is the SOLE invoker of its own handle, so the slot is present on
316        // the live path. A generation mismatch (a stale handle — e.g. a future
317        // async-pool deferred callback firing after the node dropped + the slot was
318        // recycled) yields None → a silent no-op, never a recycled-slot misfire.
319        if let Some(f) = f {
320            if recording {
321                let _profile = InvokeProfileGuard {
322                    dispatcher: self.clone(),
323                    handle,
324                    start: Instant::now(),
325                };
326                f(ctx);
327            } else {
328                f(ctx);
329            }
330        }
331    }
332}
333
334struct InvokeProfileGuard {
335    dispatcher: Dispatcher,
336    handle: Handle,
337    start: Instant,
338}
339
340impl Drop for InvokeProfileGuard {
341    fn drop(&mut self) {
342        let elapsed = self.start.elapsed().as_nanos();
343        let mut inner = self.dispatcher.0.borrow_mut();
344        let stat = inner.stats.entry(self.handle).or_default();
345        stat.invokes += 1;
346        stat.total_duration_ns += elapsed;
347        stat.last_duration_ns = elapsed;
348    }
349}
350
351impl Default for Dispatcher {
352    fn default() -> Self {
353        Self::new()
354    }
355}
356
357thread_local! {
358    /// The only global singleton (D26) — thread-local because the substrate is
359    /// single-thread / `!Send` (D22). Overridable by passing an explicit dispatcher.
360    static DEFAULT: Dispatcher = Dispatcher::new();
361}
362
363/// A clone of the thread-local default dispatcher (D26).
364pub fn default_dispatcher() -> Dispatcher {
365    DEFAULT.with(|d| d.clone())
366}