Skip to main content

subetha_cxc/
reactor.rs

1//! `reactor`: the bridge that makes a SubEtha ring a first-class async
2//! source ACROSS processes, not just across threads.
3//!
4//! Intra-process async is direct: the producer holds the consumer's
5//! `Waker` and fires it on push (see [`crate::waker_ring`]). Across
6//! processes the producer is in another address space and cannot touch
7//! a local `Waker`, so a parked future needs something in THIS process
8//! to notice the cross-process publish and fire its `Waker`. That is
9//! the reactor: one background thread per process that blocks on the
10//! MMF [`CrossProcessWaker`], and when another process publishes, fires
11//! the local `Waker` of the
12//! future parked on the ring. It is the epoll/IOCP reactor pattern with
13//! the readiness source being a shared-memory ring head instead of a
14//! socket.
15//!
16//! # One surface, two locales
17//!
18//! [`ReactiveReceiver::recv`] returns the same future whether the
19//! producer is a thread or a process:
20//!  - [`anon_pair`] builds an intra-process channel; the sender fires
21//!    the receiver's `Waker` directly, no reactor thread.
22//!  - [`receiver_cross`] / [`sender_cross`] build the cross-process
23//!    halves over a shared MMF ring + named waker; a reactor thread in
24//!    the consumer bridges the publish to the local `Waker`.
25//!
26//! Unlike [`crate::async_ring`], which spawns one OS thread per
27//! in-flight future, the reactor uses ONE thread per process regardless
28//! of how many futures park on the ring.
29//!
30//! # `block_on`
31//!
32//! [`block_on`] is a minimal thread-parking driver: it sleeps the
33//! calling thread between polls and is unparked by the future's `Waker`.
34//! Paired with the reactor, a consumer process genuinely sleeps (both
35//! the driver thread and the reactor thread park in the kernel) until
36//! another process publishes - no busy-spin.
37
38use std::future::Future;
39use std::pin::Pin;
40use std::sync::Arc;
41use std::sync::atomic::{AtomicBool, Ordering};
42use std::task::{Context, Poll, Wake, Waker};
43use std::thread::JoinHandle;
44use std::time::Duration;
45
46use parking_lot::Mutex;
47
48use crate::cross_process_waker::CrossProcessWaker;
49use crate::shared_ring::RingError;
50use crate::spsc_ring::{SpscRingCore, SPSC_PAYLOAD_BYTES};
51
52/// Maximum the reactor sleeps per wait before re-checking the ring head,
53/// independent of the cross-process wake. The wake (the common path)
54/// returns far sooner; this tick only matters when a wake is lost to the
55/// `CrossProcessWaker` register/wake visibility race, which the head
56/// re-check at the loop top then heals. Bounded, so a lost wake cannot
57/// hang the consumer; large enough that an idle reactor barely ticks.
58const REACTOR_HEAL_INTERVAL: Duration = Duration::from_millis(50);
59
60/// Env-gated wake-path trace (`SUBETHA_WAKE_TRACE=1`): the reactor
61/// prints a per-second counter snapshot and `serve_one` prints its
62/// read/wake ledger, so a stalled pipeline names the stage whose
63/// counter froze.
64pub(crate) fn wake_trace() -> bool {
65    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
66    *ON.get_or_init(|| std::env::var_os("SUBETHA_WAKE_TRACE").is_some_and(|v| v == "1"))
67}
68
69/// Wake-path counters, process-wide. Futures aggregate across
70/// instances; reactor snapshots carry the ring address to tell
71/// concurrent reactors apart.
72pub(crate) static POLLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
73pub(crate) static POPS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
74pub(crate) static REGISTERS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
75
76/// A `Waker` that unparks a specific thread. The driver behind
77/// [`block_on`].
78struct ThreadWaker {
79    thread: std::thread::Thread,
80}
81
82impl Wake for ThreadWaker {
83    fn wake(self: Arc<Self>) {
84        self.thread.unpark();
85    }
86    fn wake_by_ref(self: &Arc<Self>) {
87        self.thread.unpark();
88    }
89}
90
91/// Drive a future to completion on the current thread, parking the
92/// thread between polls. The future's `Waker` unparks it; a reactor
93/// (or a local sender) fires that `Waker` on readiness.
94pub fn block_on<F: Future>(future: F) -> F::Output {
95    let mut future = Box::pin(future);
96    let waker = Waker::from(Arc::new(ThreadWaker {
97        thread: std::thread::current(),
98    }));
99    let mut cx = Context::from_waker(&waker);
100    loop {
101        match future.as_mut().poll(&mut cx) {
102            Poll::Ready(v) => return v,
103            // A spurious unpark just re-polls (which re-checks the ring
104            // and re-parks), so this is correct without a flag.
105            Poll::Pending => std::thread::park(),
106        }
107    }
108}
109
110/// Where a sender's push delivers its readiness signal.
111enum SenderSignal {
112    /// Intra-process: fire the receiver's `Waker` directly.
113    Local(Arc<Mutex<Option<Waker>>>),
114    /// Cross-process: wake the consumer's reactor through the MMF.
115    Cross(Arc<CrossProcessWaker>),
116}
117
118/// Producer half. `try_send` publishes the payload and signals the
119/// consumer - a direct `Waker` fire intra-process, an MMF wake
120/// cross-process.
121pub struct ReactiveSender {
122    ring: Arc<SpscRingCore>,
123    signal: SenderSignal,
124}
125
126impl ReactiveSender {
127    /// Push a payload and signal the consumer. Returns `Err(Full)` when
128    /// the ring is full (the signal is sent only on a successful push).
129    pub fn try_send(&self, payload: &[u8]) -> Result<(), RingError> {
130        self.ring.try_push(payload)?;
131        match &self.signal {
132            SenderSignal::Local(slot) => {
133                if let Some(w) = slot.lock().take() {
134                    w.wake();
135                }
136            }
137            SenderSignal::Cross(xwaker) => {
138                xwaker.wake_up_to(self.ring.head());
139            }
140        }
141        Ok(())
142    }
143
144    /// The producer's published item count (ring head).
145    pub fn published(&self) -> u64 {
146        self.ring.head()
147    }
148}
149
150/// Consumer half. `recv()` is an `.await`-able future that resolves
151/// when an item arrives, suspending the task until then - off-thread
152/// across threads OR across processes, behind the same call.
153pub struct ReactiveReceiver {
154    ring: Arc<SpscRingCore>,
155    slot: Arc<Mutex<Option<Waker>>>,
156    /// Present only in cross-process mode; owns the reactor thread and
157    /// stops it on drop.
158    _reactor: Option<ReactorHandle>,
159}
160
161impl ReactiveReceiver {
162    /// A future resolving to the next slot's bytes. Owns clones of the
163    /// ring + waker slot, so it is `Send + 'static`.
164    pub fn recv(&self) -> ReactiveRecv {
165        ReactiveRecv {
166            ring: Arc::clone(&self.ring),
167            slot: Arc::clone(&self.slot),
168        }
169    }
170
171    /// Non-blocking pop, for draining without awaiting.
172    pub fn try_recv(&self, out: &mut [u8]) -> Result<usize, RingError> {
173        self.ring.try_pop(out)
174    }
175}
176
177/// Future returned by [`ReactiveReceiver::recv`].
178pub struct ReactiveRecv {
179    ring: Arc<SpscRingCore>,
180    slot: Arc<Mutex<Option<Waker>>>,
181}
182
183impl Future for ReactiveRecv {
184    type Output = [u8; SPSC_PAYLOAD_BYTES];
185
186    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
187        POLLS.fetch_add(1, Ordering::Relaxed);
188        let mut out = [0u8; SPSC_PAYLOAD_BYTES];
189        if self.ring.try_pop(&mut out).is_ok() {
190            POPS.fetch_add(1, Ordering::Relaxed);
191            return Poll::Ready(out);
192        }
193        // Register, then re-check: an item that landed between the first
194        // pop and this registration is caught here, not lost.
195        *self.slot.lock() = Some(cx.waker().clone());
196        REGISTERS.fetch_add(1, Ordering::Relaxed);
197        if self.ring.try_pop(&mut out).is_ok() {
198            POPS.fetch_add(1, Ordering::Relaxed);
199            return Poll::Ready(out);
200        }
201        Poll::Pending
202    }
203}
204
205/// Owns the reactor thread and stops it when the receiver drops.
206struct ReactorHandle {
207    shutdown: Arc<AtomicBool>,
208    xwaker: Arc<CrossProcessWaker>,
209    join: Option<JoinHandle<()>>,
210}
211
212impl Drop for ReactorHandle {
213    fn drop(&mut self) {
214        self.shutdown.store(true, Ordering::Release);
215        // Unblock the reactor's wait() so it sees the shutdown flag.
216        self.xwaker.wake_all();
217        if let Some(j) = self.join.take() {
218            j.join().ok();
219        }
220    }
221}
222
223/// Intra-process reactive channel: the sender fires the receiver's
224/// `Waker` directly on push. No reactor thread. `capacity` must be a
225/// power of two.
226pub fn anon_pair(
227    capacity: usize,
228) -> Result<(ReactiveSender, ReactiveReceiver), RingError> {
229    let ring = Arc::new(SpscRingCore::create_anon(capacity)?);
230    let slot = Arc::new(Mutex::new(None));
231    Ok((
232        ReactiveSender {
233            ring: Arc::clone(&ring),
234            signal: SenderSignal::Local(Arc::clone(&slot)),
235        },
236        ReactiveReceiver { ring, slot, _reactor: None },
237    ))
238}
239
240/// Cross-process producer half over a shared MMF ring + named waker.
241/// The two processes share the same ring file and the same waker file.
242pub fn sender_cross(
243    ring: Arc<SpscRingCore>,
244    xwaker: Arc<CrossProcessWaker>,
245) -> ReactiveSender {
246    ReactiveSender { ring, signal: SenderSignal::Cross(xwaker) }
247}
248
249/// Cross-process consumer half. Spawns a reactor thread that blocks on
250/// the shared waker and fires the local `Waker` of the future parked on
251/// the ring whenever the producer process publishes.
252pub fn receiver_cross(
253    ring: Arc<SpscRingCore>,
254    xwaker: Arc<CrossProcessWaker>,
255) -> ReactiveReceiver {
256    let slot: Arc<Mutex<Option<Waker>>> = Arc::new(Mutex::new(None));
257    let shutdown = Arc::new(AtomicBool::new(false));
258
259    let join = {
260        let ring = Arc::clone(&ring);
261        let xwaker = Arc::clone(&xwaker);
262        let slot = Arc::clone(&slot);
263        let shutdown = Arc::clone(&shutdown);
264        std::thread::spawn(move || reactor_loop(ring, xwaker, slot, shutdown))
265    };
266
267    ReactiveReceiver {
268        ring,
269        slot,
270        _reactor: Some(ReactorHandle {
271            shutdown,
272            xwaker,
273            join: Some(join),
274        }),
275    }
276}
277
278/// The reactor: bridge cross-process publishes to the local `Waker`.
279/// Blocks on the MMF waker; on every observed head advance fires the
280/// parked future's `Waker` so the driver re-polls and pops.
281fn reactor_loop(
282    ring: Arc<SpscRingCore>,
283    xwaker: Arc<CrossProcessWaker>,
284    slot: Arc<Mutex<Option<Waker>>>,
285    shutdown: Arc<AtomicBool>,
286) {
287    let trace = wake_trace();
288    let (mut iters, mut advances, mut fires, mut fires_empty) = (0u64, 0u64, 0u64, 0u64);
289    let (mut parks, mut park_full, mut waits) = (0u64, 0u64, 0u64);
290    let mut last_snap = std::time::Instant::now();
291    let mut last = ring.head();
292    loop {
293        if trace {
294            iters += 1;
295            if last_snap.elapsed() >= Duration::from_secs(1) {
296                last_snap = std::time::Instant::now();
297                eprintln!(
298                    "subetha: reactor ring={:p} head={} iters={iters} adv={advances} \
299                     fires={fires} fires_empty={fires_empty} parks={parks} \
300                     park_full={park_full} waits={waits} | futures polls={} pops={} regs={}",
301                    Arc::as_ptr(&ring),
302                    ring.head(),
303                    POLLS.load(Ordering::Relaxed),
304                    POPS.load(Ordering::Relaxed),
305                    REGISTERS.load(Ordering::Relaxed),
306                );
307            }
308        }
309        if shutdown.load(Ordering::Acquire) {
310            break;
311        }
312        let head = ring.head();
313        if head != last {
314            last = head;
315            advances += 1;
316            if let Some(w) = slot.lock().take() {
317                fires += 1;
318                w.wake();
319            } else {
320                fires_empty += 1;
321            }
322            continue;
323        }
324        // A parked future with a non-empty ring is a lost fire: the
325        // future's register-then-recheck means it only parks against an
326        // empty ring, so items it never saw were published after its
327        // registration - and if they landed before this thread's first
328        // head read, the baseline above swallowed the advance. Fire
329        // here regardless of head history.
330        if ring.head() != ring.tail()
331            && let Some(w) = slot.lock().take()
332        {
333            fires += 1;
334            w.wake();
335            continue;
336        }
337        // Park until the producer publishes past `head`.
338        match xwaker.try_park(head + 1) {
339            Ok(token) => {
340                parks += 1;
341                // Lost-wake guard: an item that landed (or a shutdown
342                // that fired) between the head read and the park is
343                // caught here.
344                if shutdown.load(Ordering::Acquire) || ring.head() != head {
345                    xwaker.release(token);
346                    continue;
347                }
348                // Heal-bounded: a real cross-process wake (producer
349                // publish) or the shutdown `wake_all` ends the wait fast;
350                // the bounded tick is the backstop so a wake lost to the
351                // register/visibility race self-heals at the loop top
352                // (head re-check) instead of hanging the consumer.
353                xwaker.wait(token, Some(REACTOR_HEAL_INTERVAL)).ok();
354                waits += 1;
355            }
356            Err(crate::cross_process_waker::WakerError::Full) => {
357                park_full += 1;
358                // No free waker slot; re-check shortly.
359                std::hint::spin_loop();
360            }
361            Err(_) => break,
362        }
363    }
364}
365
366/// A bridge from an arbitrary monotonic published-seq source to a local
367/// `Waker` slot, for channels whose backing is not an `SpscRingCore`
368/// (e.g. a [`SharedRing`](crate::SharedRing)'s `producer_seq` /
369/// `consumer_seq`). Same heal-bounded loop as [`reactor_loop`]; the
370/// closure supplies the count. Stops its thread on drop.
371pub(crate) struct SeqReactor {
372    shutdown: Arc<AtomicBool>,
373    xwaker: Arc<CrossProcessWaker>,
374    join: Option<JoinHandle<()>>,
375}
376
377impl Drop for SeqReactor {
378    fn drop(&mut self) {
379        self.shutdown.store(true, Ordering::Release);
380        self.xwaker.wake_all();
381        if let Some(j) = self.join.take() {
382            j.join().ok();
383        }
384    }
385}
386
387/// Spawn a reactor firing `slot` whenever `published()` advances,
388/// parking on `xwaker` between observations.
389pub(crate) fn spawn_seq_reactor(
390    published: Arc<dyn Fn() -> u64 + Send + Sync>,
391    xwaker: Arc<CrossProcessWaker>,
392    slot: Arc<Mutex<Option<Waker>>>,
393) -> SeqReactor {
394    let shutdown = Arc::new(AtomicBool::new(false));
395    let join = {
396        let xwaker = Arc::clone(&xwaker);
397        let shutdown = Arc::clone(&shutdown);
398        std::thread::spawn(move || seq_reactor_loop(published, &xwaker, &slot, &shutdown))
399    };
400    SeqReactor { shutdown, xwaker, join: Some(join) }
401}
402
403fn seq_reactor_loop(
404    published: Arc<dyn Fn() -> u64 + Send + Sync>,
405    xwaker: &CrossProcessWaker,
406    slot: &Mutex<Option<Waker>>,
407    shutdown: &AtomicBool,
408) {
409    // Sentinel baseline: the first pass always takes the advance
410    // branch, so anything published before this thread's first read is
411    // fired for rather than silently becoming the baseline.
412    let mut last = u64::MAX;
413    loop {
414        if shutdown.load(Ordering::Acquire) {
415            break;
416        }
417        let cur = published();
418        if cur != last {
419            last = cur;
420            if let Some(w) = slot.lock().take() {
421                w.wake();
422            }
423            continue;
424        }
425        match xwaker.try_park(cur + 1) {
426            Ok(token) => {
427                if shutdown.load(Ordering::Acquire) || published() != cur {
428                    xwaker.release(token);
429                    continue;
430                }
431                xwaker.wait(token, Some(REACTOR_HEAL_INTERVAL)).ok();
432            }
433            Err(crate::cross_process_waker::WakerError::Full) => {
434                std::hint::spin_loop();
435            }
436            Err(_) => break,
437        }
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use std::sync::atomic::{AtomicU64, Ordering};
445    use std::thread;
446
447    #[test]
448    fn intra_process_block_on_parks_and_wakes_on_send() {
449        // The driver thread parks between items; the sender's direct
450        // Waker fire unparks it. Proves the recv future suspends rather
451        // than spins, with no reactor thread in this locale.
452        let (tx, rx) = anon_pair(8).unwrap();
453        const N: u64 = 1000;
454
455        let producer = thread::spawn(move || {
456            for i in 0..N {
457                let mut buf = [0u8; SPSC_PAYLOAD_BYTES];
458                buf[..8].copy_from_slice(&i.to_le_bytes());
459                while tx.try_send(&buf).is_err() {
460                    std::hint::spin_loop();
461                }
462            }
463        });
464
465        let sum = block_on(async move {
466            let mut s = 0u64;
467            for _ in 0..N {
468                let item = rx.recv().await;
469                s += u64::from_le_bytes(item[..8].try_into().unwrap());
470            }
471            s
472        });
473
474        producer.join().unwrap();
475        assert_eq!(sum, (0..N).sum());
476    }
477
478    #[test]
479    fn many_futures_one_local_signal() {
480        // A second consumer task (driven on a worker) also wakes on the
481        // same channel's sender fire; verifies the slot-register /
482        // re-check path under interleaving.
483        let (tx, rx) = anon_pair(4).unwrap();
484        let got = Arc::new(AtomicU64::new(0));
485        let got2 = Arc::clone(&got);
486
487        let consumer = thread::spawn(move || {
488            block_on(async move {
489                for _ in 0..500u64 {
490                    let item = rx.recv().await;
491                    got2.fetch_add(
492                        u64::from_le_bytes(item[..8].try_into().unwrap()),
493                        Ordering::AcqRel,
494                    );
495                }
496            });
497        });
498
499        for i in 0..500u64 {
500            let mut buf = [0u8; SPSC_PAYLOAD_BYTES];
501            buf[..8].copy_from_slice(&i.to_le_bytes());
502            while tx.try_send(&buf).is_err() {
503                std::hint::spin_loop();
504            }
505        }
506        consumer.join().unwrap();
507        assert_eq!(got.load(Ordering::Acquire), (0..500u64).sum());
508    }
509}