Skip to main content

proxima_runtime/
lib.rs

1//! Runtime trait surface. Abstract scheduling primitives that
2//! concrete runtimes (`TokioPerCoreRuntime`, `PrimeRuntime`,
3//! `RayonBackgroundPool`) implement.
4//!
5//! The trait and value types live here, out of the proxima umbrella, so
6//! `prime` can be a separate crate without depending on it.
7
8#![cfg_attr(not(feature = "std"), no_std)]
9
10#[cfg(feature = "alloc")]
11extern crate alloc;
12
13#[cfg(feature = "rayon")]
14pub mod background_rayon;
15#[cfg(feature = "rayon")]
16pub use background_rayon::RayonBackgroundPool;
17
18#[cfg(feature = "concurrency")]
19pub mod concurrency;
20
21/// Fluent spawn surface over the object-safe `Runtime`. Carries the axis
22/// combinations the trait's named methods pre-combine.
23#[cfg(feature = "alloc")]
24pub mod ext;
25
26#[cfg(feature = "alloc")]
27pub mod primitives;
28#[cfg(feature = "alloc")]
29pub use primitives::{
30    JoinError, JoinSetLike, LocalMutexLike, LocalNotifyLike, MutexLike, NotifyLike,
31};
32#[cfg(all(feature = "alloc", feature = "std"))]
33pub use primitives::{LocalRuntimeFactory, RuntimeFactory};
34
35// Default `Runtime` impl: N tokio current-thread runtimes pinned one per CPU
36// core (folded from the former proxima-runtime-tokio crate — FOLD 1 of the
37// runtime backend consolidation). Nested under its own module (not the crate
38// root) because it sits alongside this crate's own `primitives` module above
39// (the RuntimeFactory traits) — nesting avoids the name collision.
40#[cfg(feature = "tokio")]
41pub mod tokio;
42
43use thiserror::Error;
44
45#[cfg(feature = "alloc")]
46use core::convert::Infallible;
47#[cfg(feature = "alloc")]
48use core::future::Future;
49#[cfg(feature = "alloc")]
50use core::pin::Pin;
51
52#[cfg(feature = "alloc")]
53use proxima_core::ProximaError;
54
55#[cfg(feature = "alloc")]
56use alloc::boxed::Box;
57
58/// Cross-core worker dispatch message used by `Runtime` impls. Generic
59/// over an `Inline` extension type so prime's typed-future fast path can
60/// add a variant (`SendInline`) carrying its `InlineTask`, while tokio's
61/// per-core impl uses the default (`Infallible`) which makes the
62/// `SendInline` arm unreachable.
63///
64/// Trace-span context no longer travels embedded in this enum (moved to
65/// `telemetry::Spanned<T>`, which wraps the future itself) — see Wave D
66/// Phase 1.
67#[cfg(feature = "alloc")]
68pub enum SpawnRequest<Inline = Infallible> {
69    /// A `Send` future shipped across cores; runs locally on arrival.
70    Send(Pin<Box<dyn Future<Output = ()> + Send + 'static>>),
71    /// A `Send` closure that builds a `?Send` future on the target core.
72    /// Lets cross-thread callers dispatch per-core-only futures.
73    Factory(Box<dyn FnOnce() -> Pin<Box<dyn Future<Output = ()> + 'static>> + Send + 'static>),
74    /// Runtime-impl-specific extension. Default (`Infallible`) makes this
75    /// arm unreachable. Prime sets `Inline = InlineTask` to carry typed
76    /// futures without the `Pin<Box<dyn Future>>` allocation.
77    SendInline(Inline),
78    /// Graceful shutdown signal — the worker breaks its event loop.
79    Shutdown,
80}
81
82#[cfg(feature = "alloc")]
83impl<Inline> core::fmt::Debug for SpawnRequest<Inline> {
84    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
85        match self {
86            Self::Send(..) => formatter.write_str("SpawnRequest::Send"),
87            Self::Factory(..) => formatter.write_str("SpawnRequest::Factory"),
88            Self::SendInline(..) => formatter.write_str("SpawnRequest::SendInline"),
89            Self::Shutdown => formatter.write_str("SpawnRequest::Shutdown"),
90        }
91    }
92}
93
94/// Identifier for a pinned core in the runtime. Opaque; not assumed to
95/// map 1:1 onto OS CPU ids. Construction is implementation-specific.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
97pub struct CoreId(pub usize);
98
99impl From<usize> for CoreId {
100    fn from(index: usize) -> Self {
101        CoreId(index)
102    }
103}
104
105impl CoreId {
106    #[must_use]
107    pub fn as_usize(&self) -> usize {
108        self.0
109    }
110}
111
112/// Handle to a task running on the background work-stealing pool. The
113/// chain runtime stays per-core; this handle lets a Pipe await a
114/// CPU-bound side computation that *does* migrate between threads.
115#[cfg(feature = "alloc")]
116pub type BackgroundHandle<R> = Pin<Box<dyn Future<Output = Result<R, ProximaError>> + Send>>;
117
118/// Pluggable backend for cross-thread CPU-bound work.
119///
120/// The chain `Runtime` is per-core (`!Send` futures). When a Pipe
121/// needs to do work that *does* migrate threads — image decoding,
122/// model inference, parallel parsing, fork-join compute — it routes
123/// through here. The pool runs on its own threads; the returned
124/// handle is `Send` so the per-core caller can await it across the
125/// chain-runtime / pool boundary.
126///
127/// Default: `tokio::task::spawn_blocking` (what `Runtime::spawn_background_blocking`
128/// does in `TokioPerCoreRuntime`, feature `tokio`, when no pool is installed
129/// with `TokioPerCoreRuntime::with_background_pool` — good for I/O-blocking
130/// work). Alternative: `RayonBackgroundPool` (feature `rayon`) — work-
131/// stealing across a fixed thread count, the right shape for
132/// CPU-bound parallel compute.
133#[cfg(feature = "alloc")]
134pub trait BackgroundPool: Send + Sync + 'static {
135    fn spawn(
136        &self,
137        work: Box<dyn FnOnce() -> Result<Box<dyn core::any::Any + Send>, ProximaError> + Send>,
138    ) -> BackgroundHandle<Box<dyn core::any::Any + Send>>;
139}
140
141/// Failure modes for cross-core spawn dispatch. Surfaced by
142/// `Runtime::spawn_on_core` and `Runtime::spawn_factory_on_core` so callers
143/// must explicitly handle back-pressure rather than relying on silent
144/// drops — which was the cause of the inbox-saturation hang exposed by
145/// `runtime_spawn_on_core_silently_drops_on_inbox_overflow` in earlier
146/// versions.
147///
148/// `InboxFull` is *transient*: retry by rebuilding the future and calling
149/// again, ideally with a small backoff. The `spawn_on_core_blocking_with`
150/// helper below automates this loop.
151///
152/// `Disconnected` is *terminal*: the target core has shut down. The
153/// future is dropped; retrying will return the same error.
154///
155/// Implementations that cannot fail on back-pressure (e.g. tokio's
156/// `flume::unbounded()` mpsc never returns Full) will simply never
157/// produce `InboxFull` and callers see `Ok(())` in the steady state.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
159pub enum SpawnError {
160    /// The target core's inbox lane is at capacity. The future has been
161    /// dropped; the caller should rebuild and retry (see
162    /// [`spawn_on_core_blocking_with`]).
163    #[error("inbox lane full; transient back-pressure")]
164    InboxFull,
165    /// The target core has shut down or never existed (out-of-range
166    /// `CoreId`). The future is dropped; retrying is futile.
167    #[error("target core disconnected")]
168    Disconnected,
169}
170
171/// Loop on `spawn_on_core` until the future is accepted (`Ok`) or the
172/// target core is shut down (`SpawnError::Disconnected`). The factory
173/// closure is invoked once per attempt so the future is freshly
174/// constructed each time — necessary because `spawn_on_core` consumes
175/// the future on every call (including failures).
176///
177/// This is the canonical helper for batch dispatchers (load generators,
178/// bench harnesses, mass-spawn migrations) that genuinely want
179/// back-pressure absorbed at the caller via busy-yield rather than
180/// surfaced as an error. Production code that has a meaningful response
181/// to `InboxFull` (shed load, return 503, etc.) should call
182/// `spawn_on_core` directly.
183///
184/// Returns `Ok(())` on success or `Err(SpawnError::Disconnected)` if the
185/// target core is gone.
186#[cfg(feature = "alloc")]
187pub fn spawn_on_core_blocking_with<F>(
188    runtime: &dyn Runtime,
189    core_id: CoreId,
190    mut factory: F,
191) -> Result<(), SpawnError>
192where
193    F: FnMut() -> Pin<Box<dyn Future<Output = ()> + Send + 'static>>,
194{
195    loop {
196        match runtime.spawn_on_core(core_id, factory()) {
197            Ok(()) => return Ok(()),
198            Err(SpawnError::InboxFull) => {
199                #[cfg(feature = "std")]
200                std::thread::yield_now();
201                #[cfg(not(feature = "std"))]
202                core::hint::spin_loop();
203            }
204            Err(SpawnError::Disconnected) => return Err(SpawnError::Disconnected),
205        }
206    }
207}
208
209/// Drive `future` to completion on a [`Runtime`] you already HOLD, returning
210/// its output. The runtime-backed sibling of the no-runtime
211/// `proxima_primitives::block_on` poll loop: same verb ("drive a future to
212/// completion"), but the future runs on `runtime`'s core 0 worker instead of
213/// the calling thread. Composes [`Runtime::spawn_on_core`] with a
214/// `sync_channel(1)` for the thread-park / value handoff — the exact core of
215/// the edge `run_prime` driver, lifted off the concrete `PrimeRuntime` onto
216/// `&dyn Runtime` (a free fn, not a trait method, so `dyn Runtime` stays
217/// object-safe).
218///
219/// FOREIGN-THREAD entry: call this from a thread that is NOT a worker of
220/// `runtime`, or you deadlock the worker (the same rule as
221/// `tokio::runtime::Runtime::block_on`). The edge `run_prime` avoids this by
222/// booting a dedicated driver core; this primitive does not — it drives on
223/// core 0 and parks the caller.
224///
225/// # Errors
226/// Returns `ProximaError::Io` if core 0's inbox rejects the dispatch
227/// (`InboxFull`/`Disconnected`) or the worker drops the completion channel
228/// without producing a value.
229#[cfg(all(feature = "alloc", feature = "std"))]
230#[must_use = "block_on returns the future's output or the dispatch error"]
231pub fn block_on<F>(runtime: &dyn Runtime, future: F) -> Result<F::Output, ProximaError>
232where
233    F: Future + Send + 'static,
234    F::Output: Send + 'static,
235{
236    let (sender, receiver) = std::sync::mpsc::sync_channel::<F::Output>(1);
237    let task = async move {
238        let output = future.await;
239        let _ = sender.send(output);
240    };
241    match runtime.spawn_on_core(CoreId(0), Box::pin(task)) {
242        Ok(()) => {}
243        Err(SpawnError::InboxFull) => {
244            return Err(block_on_dispatch_error(
245                "runtime core 0 inbox full on block_on dispatch",
246            ));
247        }
248        Err(SpawnError::Disconnected) => {
249            return Err(block_on_dispatch_error(
250                "runtime core 0 disconnected on block_on dispatch",
251            ));
252        }
253    }
254    receiver.recv().map_err(|_| {
255        block_on_dispatch_error("runtime worker dropped the block_on completion channel")
256    })
257}
258
259#[cfg(all(feature = "alloc", feature = "std"))]
260fn block_on_dispatch_error(message: &str) -> ProximaError {
261    ProximaError::Io(std::io::Error::other(message))
262}
263
264/// Per-core executor abstraction. Implementations select the concrete
265/// threading + I/O model:
266/// - **Default**: tokio current-thread runtimes, one per pinned CPU core,
267///   spawned at app construction (this crate's `tokio` module, feature
268///   `tokio`; the umbrella spells the same switch `runtime-tokio`).
269/// - **Alternatives**: monoio, glommio, custom kernels — all addressable
270///   via the same trait surface.
271///
272/// Futures spawned via `spawn_on_current_core` are `!Send` by intent: each
273/// task is pinned to its core for life. `spawn_on_core` and
274/// `spawn_background_blocking` are the explicit cross-thread escape hatches.
275#[cfg(feature = "alloc")]
276pub trait Runtime: Send + Sync + 'static {
277    /// Spawn a future on the current core's executor. Future is `!Send` by
278    /// default — it lives on this thread until completion. Use this for
279    /// per-connection handlers, per-request work, and runtime-internal
280    /// bookkeeping.
281    fn spawn_on_current_core(&self, future: Pin<Box<dyn Future<Output = ()> + 'static>>);
282
283    /// Spawn a future on a *designated* core's executor. The future must
284    /// be `Send` because it traverses the per-core message channel before
285    /// the target core picks it up.
286    ///
287    /// Returns `Err(SpawnError::InboxFull)` when the target core's inbox
288    /// lane is at capacity — callers must explicitly choose to retry
289    /// (use [`spawn_on_core_blocking_with`]) or shed load. **The future
290    /// is consumed on `Err`**; retrying requires rebuilding it.
291    /// Returns `Err(SpawnError::Disconnected)` when the target core has
292    /// shut down.
293    fn spawn_on_core(
294        &self,
295        core_id: CoreId,
296        future: Pin<Box<dyn Future<Output = ()> + Send + 'static>>,
297    ) -> Result<(), SpawnError>;
298
299    /// Cross-thread dispatch of a future *factory*: a `Send` closure that
300    /// runs on the target core and there constructs a `?Send` future. The
301    /// factory and its captures cross the per-core channel; the future
302    /// itself is built locally on the destination core and stays there for
303    /// life. This is what lets a caller (e.g. `App::run_until_signal` running
304    /// on outer tokio) inject a per-core listener loop that internally
305    /// awaits `?Send` `Pipe::call`s.
306    ///
307    /// Same back-pressure semantics as [`Self::spawn_on_core`]: returns
308    /// `Err(SpawnError::InboxFull)` on saturation (factory is consumed),
309    /// `Err(SpawnError::Disconnected)` on shutdown.
310    fn spawn_factory_on_core(
311        &self,
312        core_id: CoreId,
313        factory: Box<dyn FnOnce() -> Pin<Box<dyn Future<Output = ()> + 'static>> + Send + 'static>,
314    ) -> Result<(), SpawnError>;
315
316    /// Submit a CPU-bound blocking task to the background work-stealing pool.
317    /// The returned handle is `Send` so the awaiter (which lives on a per-core
318    /// runtime) can park until the result is ready, then resume on its core.
319    fn spawn_background_blocking(
320        &self,
321        work: Box<dyn FnOnce() -> Result<Box<dyn core::any::Any + Send>, ProximaError> + Send>,
322    ) -> BackgroundHandle<Box<dyn core::any::Any + Send>>;
323
324    /// Yield a future that resolves at `deadline`. Used for timeouts and
325    /// scheduled work. Implementations drive the timer via their per-core
326    /// timer wheel. Only available with `std` because `Instant` requires
327    /// std — keeps `&dyn Runtime` object-safe without an associated type.
328    #[cfg(feature = "std")]
329    fn timer_at(&self, deadline: std::time::Instant)
330    -> Pin<Box<dyn Future<Output = ()> + 'static>>;
331
332    /// Number of pinned cores. Stable across the lifetime of the runtime.
333    fn num_cores(&self) -> usize;
334
335    /// The current core's id. Implementations panic if called from outside
336    /// a runtime worker thread — that's a bug at the call site, not a
337    /// recoverable error.
338    fn current_core(&self) -> CoreId;
339}
340
341#[cfg(test)]
342#[allow(clippy::unwrap_used, clippy::expect_used)]
343mod tests {
344    use super::*;
345
346    #[cfg(feature = "alloc")]
347    use alloc::boxed::Box;
348
349    #[test]
350    fn core_id_round_trips_as_usize() {
351        let id = CoreId(7);
352        assert_eq!(id.as_usize(), 7);
353    }
354
355    #[test]
356    fn spawn_error_inbox_full_display() {
357        let err = SpawnError::InboxFull;
358        assert_eq!(err.to_string(), "inbox lane full; transient back-pressure");
359    }
360
361    #[test]
362    fn spawn_error_disconnected_display() {
363        let err = SpawnError::Disconnected;
364        assert_eq!(err.to_string(), "target core disconnected");
365    }
366
367    #[test]
368    fn spawn_error_source_is_none() {
369        use core::error::Error;
370        assert!(SpawnError::InboxFull.source().is_none());
371        assert!(SpawnError::Disconnected.source().is_none());
372    }
373
374    #[cfg(feature = "alloc")]
375    mod alloc_tests {
376        use super::*;
377        use core::future::Future;
378        use core::pin::Pin;
379        use core::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
380
381        struct MockRuntime {
382            send_dispatched: core::sync::atomic::AtomicBool,
383            factory_dispatched: core::sync::atomic::AtomicBool,
384        }
385
386        impl MockRuntime {
387            fn new() -> Self {
388                Self {
389                    send_dispatched: core::sync::atomic::AtomicBool::new(false),
390                    factory_dispatched: core::sync::atomic::AtomicBool::new(false),
391                }
392            }
393        }
394
395        impl Runtime for MockRuntime {
396            fn spawn_on_current_core(&self, _future: Pin<Box<dyn Future<Output = ()> + 'static>>) {
397                self.send_dispatched
398                    .store(true, core::sync::atomic::Ordering::Relaxed);
399            }
400
401            fn spawn_on_core(
402                &self,
403                _core_id: CoreId,
404                _future: Pin<Box<dyn Future<Output = ()> + Send + 'static>>,
405            ) -> Result<(), SpawnError> {
406                self.send_dispatched
407                    .store(true, core::sync::atomic::Ordering::Relaxed);
408                Ok(())
409            }
410
411            fn spawn_factory_on_core(
412                &self,
413                _core_id: CoreId,
414                _factory: Box<
415                    dyn FnOnce() -> Pin<Box<dyn Future<Output = ()> + 'static>> + Send + 'static,
416                >,
417            ) -> Result<(), SpawnError> {
418                self.factory_dispatched
419                    .store(true, core::sync::atomic::Ordering::Relaxed);
420                Ok(())
421            }
422
423            fn spawn_background_blocking(
424                &self,
425                work: Box<
426                    dyn FnOnce() -> Result<Box<dyn core::any::Any + Send>, ProximaError> + Send,
427                >,
428            ) -> BackgroundHandle<Box<dyn core::any::Any + Send>> {
429                let result = work();
430                Box::pin(async move { result })
431            }
432
433            fn num_cores(&self) -> usize {
434                1
435            }
436
437            fn current_core(&self) -> CoreId {
438                CoreId(0)
439            }
440
441            #[cfg(feature = "std")]
442            fn timer_at(
443                &self,
444                _deadline: std::time::Instant,
445            ) -> Pin<Box<dyn Future<Output = ()> + 'static>> {
446                Box::pin(async {})
447            }
448        }
449
450        fn noop_waker() -> Waker {
451            const VTABLE: RawWakerVTable =
452                RawWakerVTable::new(|ptr| RawWaker::new(ptr, &VTABLE), |_| {}, |_| {}, |_| {});
453            unsafe { Waker::from_raw(RawWaker::new(core::ptr::null(), &VTABLE)) }
454        }
455
456        #[test]
457        fn spawn_request_send_dispatches() {
458            let request: SpawnRequest = SpawnRequest::Send(Box::pin(async {}));
459            assert!(matches!(request, SpawnRequest::Send(..)));
460        }
461
462        #[test]
463        fn spawn_request_factory_dispatches() {
464            let request: SpawnRequest = SpawnRequest::Factory(Box::new(|| Box::pin(async {})));
465            assert!(matches!(request, SpawnRequest::Factory(..)));
466        }
467
468        #[test]
469        fn spawn_request_shutdown_dispatches() {
470            let request: SpawnRequest<core::convert::Infallible> = SpawnRequest::Shutdown;
471            assert!(matches!(request, SpawnRequest::Shutdown));
472        }
473
474        #[test]
475        fn background_handle_round_trip_with_payload() {
476            let rt = MockRuntime::new();
477            let payload = alloc::string::String::from("hello");
478            let mut handle = rt.spawn_background_blocking(Box::new(move || {
479                Ok(Box::new(payload) as Box<dyn core::any::Any + Send>)
480            }));
481
482            let waker = noop_waker();
483            let mut ctx = Context::from_waker(&waker);
484            let poll = Pin::new(&mut handle).poll(&mut ctx);
485
486            let result = match poll {
487                Poll::Ready(result) => result,
488                Poll::Pending => panic!("expected ready"),
489            };
490
491            let boxed = result.expect("ok result");
492            let value = boxed.downcast::<alloc::string::String>().expect("downcast");
493            assert_eq!(*value, "hello");
494        }
495
496        #[cfg(feature = "std")]
497        #[test]
498        fn timer_at_compiles_and_is_callable() {
499            let rt = MockRuntime::new();
500            let deadline = std::time::Instant::now();
501            let _future = rt.timer_at(deadline);
502        }
503    }
504}