Skip to main content

proxima_runtime/
ext.rs

1//! Fluent surface over [`Runtime`], carrying the combinations the trait's
2//! named methods pre-combine.
3//!
4//! [`Runtime`] is consumed as `Arc<dyn Runtime>` (`App::offline_runtime`, the
5//! recording spigot), so it must stay object-safe: no generics, no RPITIT, and
6//! every payload boxed. That erasure is real on the trait and needless at the
7//! call site — this trait is never used as `dyn` itself, so it may carry
8//! generics, and the boxing happens once inside it. Every method here stays
9//! callable *through* a trait object: no `Self: Sized` bounds, and the blanket
10//! impl is `?Sized`, so `Arc<dyn Runtime>` reaches the whole builder.
11//!
12//! Three independent axes the named methods weld together:
13//!
14//! | axis      | choices                                  |
15//! |-----------|------------------------------------------|
16//! | placement | current core (default) / chosen core / background pool |
17//! | payload   | future / blocking closure                |
18//! | failure   | `SpawnError` on a chosen core            |
19//!
20//! `spawn_on_current_core` binds placement and drops the `Result`;
21//! `spawn_factory_on_core` binds placement and payload in one identifier. The
22//! grid has more cells than there are names for, and this is where the rest of
23//! them live.
24//!
25//! ```text
26//! rt.spawn(fut);                       // current core
27//! rt.core(id).spawn(fut)?;             // chosen core, SpawnError surfaced
28//! rt.blocking(work).spawn().await?;    // background pool, typed R back
29//! rt.core([1, 2, 3]).spawn_with(|| work(), |_, e| *e == SpawnError::InboxFull)?;
30//! // -> Ok(CoreId) which core took it, or Err(Exhausted { attempts, last })
31//! ```
32//!
33//! `text`, not a doctest: every line needs a live `Runtime`. A doctest that
34//! cannot run is worse than prose — `ignore` compiles nothing while
35//! `cargo test --doc` still exits 0, which is how a broken example ships. The
36//! executable form of all four lines is in this module's tests.
37
38use alloc::boxed::Box;
39use core::any::Any;
40use core::future::Future;
41
42use proxima_core::ProximaError;
43
44use crate::{BackgroundHandle, CoreId, Runtime, SpawnError};
45
46/// Fluent spawn surface. Blanket-implemented for every [`Runtime`]; never used
47/// as `dyn` itself, which is what buys back the generics.
48pub trait RuntimeExt: Runtime {
49    /// Spawn a future on the current core. The trait's own
50    /// `spawn_on_current_core` discards a `SpawnError` that `spawn_on_core`
51    /// surfaces — this keeps that discard where the caller can see it.
52    fn spawn<F>(&self, future: F)
53    where
54        F: Future<Output = ()> + 'static,
55    {
56        self.spawn_on_current_core(Box::pin(future));
57    }
58
59    /// Bind the placement axis to a specific core. Terminal `spawn` then
60    /// returns `Result<(), SpawnError>` rather than swallowing back-pressure.
61    fn core(&self, placement: impl Into<Placement>) -> OnCore<'_, Self> {
62        OnCore {
63            runtime: self,
64            placement: placement.into(),
65        }
66    }
67
68    /// Bind the payload axis to work that occupies a thread. Nothing here
69    /// blocks the caller: the terminal hands back a future.
70    fn blocking<R, W>(&self, work: W) -> Blocking<'_, Self, W, R>
71    where
72        R: Send + 'static,
73        W: FnOnce() -> Result<R, ProximaError> + Send + 'static,
74    {
75        Blocking {
76            runtime: self,
77            work,
78            _result: core::marker::PhantomData,
79        }
80    }
81}
82
83impl<R: Runtime + ?Sized> RuntimeExt for R {}
84
85/// Which core, stated as intent rather than as an index the caller computed.
86///
87/// The trait's primitive takes a single [`CoreId`], so anything other than
88/// `Only` has to be resolved before dispatch — and resolution needs
89/// `current_core()` and `num_cores()`, both of which the trait already exposes.
90/// Expressing the intent here rather than at the call site is also what makes
91/// `SpawnError::InboxFull` recoverable: a candidate set can be retried across
92/// cores, where a single id can only be retried against the core that was
93/// already full.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum Placement {
96    /// This exact core.
97    Only(CoreId),
98    /// Any core except the one running this call — the usual intent when the
99    /// point is to stop competing with the caller for its own core.
100    AnyOther,
101    /// Any core at all, current included.
102    Any,
103    /// One of these, in order, falling through on back-pressure.
104    AnyOf(alloc::vec::Vec<CoreId>),
105}
106
107impl From<CoreId> for Placement {
108    fn from(core_id: CoreId) -> Self {
109        Placement::Only(core_id)
110    }
111}
112
113impl From<usize> for Placement {
114    fn from(core_id: usize) -> Self {
115        Placement::Only(CoreId(core_id))
116    }
117}
118
119impl<T: Into<CoreId>> From<alloc::vec::Vec<T>> for Placement {
120    fn from(cores: alloc::vec::Vec<T>) -> Self {
121        Placement::AnyOf(cores.into_iter().map(Into::into).collect())
122    }
123}
124
125impl<T: Into<CoreId>, const N: usize> From<[T; N]> for Placement {
126    fn from(cores: [T; N]) -> Self {
127        Placement::AnyOf(cores.into_iter().map(Into::into).collect())
128    }
129}
130
131impl Placement {
132    /// Candidate cores in dispatch order. `AnyOther` and `Any` are ordered from
133    /// the core after the current one so repeated calls spread rather than all
134    /// landing on core 0.
135    fn candidates(&self, current: CoreId, cores: usize) -> alloc::vec::Vec<CoreId> {
136        match self {
137            Placement::Only(core_id) => alloc::vec![*core_id],
138            Placement::AnyOf(list) => list.clone(),
139            Placement::Any => (0..cores)
140                .map(|offset| CoreId((current.0 + offset) % cores))
141                .collect(),
142            Placement::AnyOther => (1..cores)
143                .map(|offset| CoreId((current.0 + offset) % cores))
144                .collect(),
145        }
146    }
147}
148
149/// Every candidate was tried and none accepted — the sequence's own
150/// termination signal, kept out of [`SpawnError`] because that type answers
151/// for *one* core and its implementors produce only its two arms. Following
152/// `fan_in`'s convention: termination lives in the `Err` channel as a distinct
153/// value, not as a domain error reused to mean something else.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub struct Exhausted {
156    /// Candidates attempted. Zero means the placement resolved to nothing —
157    /// `AnyOther` on a single-core runtime, or an empty `AnyOf`.
158    pub attempts: u32,
159    /// Why the last attempt failed, absent when there were no attempts.
160    pub last: Option<SpawnError>,
161}
162
163/// Placement bound. Every stage is complete on its own — the default it
164/// replaces is a real value, not an absent one.
165pub struct OnCore<'rt, R: ?Sized> {
166    runtime: &'rt R,
167    placement: Placement,
168}
169
170impl<'rt, R: Runtime + ?Sized> OnCore<'rt, R> {
171    /// Spawn a `Send` future on the first candidate. A future can only be
172    /// spawned once, so this does not fall through on back-pressure — use
173    /// [`Self::spawn_with`] when the placement has more than one candidate.
174    pub fn spawn<F>(self, future: F) -> Result<(), SpawnError>
175    where
176        F: Future<Output = ()> + Send + 'static,
177    {
178        let candidates = self
179            .placement
180            .candidates(self.runtime.current_core(), self.runtime.num_cores());
181        let Some(core_id) = candidates.first().copied() else {
182            return Err(SpawnError::Disconnected);
183        };
184        self.runtime.spawn_on_core(core_id, Box::pin(future))
185    }
186
187    /// Spawn from a factory, letting the caller decide whether to keep going.
188    ///
189    /// `InboxFull` drops the future, so recovering means rebuilding it — hence
190    /// the factory. What it does *not* do is decide policy: no delay, no
191    /// jitter, no attempt cap live here. `decide` is called with the 0-based
192    /// attempt and the error, and returning `true` moves to the next candidate.
193    ///
194    /// That split is deliberate. `RetryController` (backoff, jitter, deadline,
195    /// `RetryRules`) lives in `proxima-primitives`, which depends on this crate
196    /// — so this crate cannot reach it, and baking a policy in here would fork
197    /// it. Passing the decision in means a caller composes with whatever it
198    /// already holds at its own layer, and `decide` being a plain `FnMut`
199    /// rather than a trait keeps that composition free.
200    pub fn spawn_with<Factory, F, Decide>(
201        self,
202        factory: Factory,
203        mut decide: Decide,
204    ) -> Result<CoreId, Exhausted>
205    where
206        Factory: Fn() -> F,
207        F: Future<Output = ()> + Send + 'static,
208        Decide: FnMut(u32, &SpawnError) -> bool,
209    {
210        let candidates = self
211            .placement
212            .candidates(self.runtime.current_core(), self.runtime.num_cores());
213        let mut attempts = 0u32;
214        let mut last = None;
215        for core_id in candidates {
216            match self.runtime.spawn_on_core(core_id, Box::pin(factory())) {
217                Ok(()) => return Ok(core_id),
218                Err(err) => {
219                    let keep_going = decide(attempts, &err);
220                    attempts = attempts.saturating_add(1);
221                    last = Some(err);
222                    if !keep_going {
223                        break;
224                    }
225                }
226            }
227        }
228        Err(Exhausted { attempts, last })
229    }
230}
231
232/// Payload bound to thread-occupying work.
233pub struct Blocking<'rt, R: ?Sized, W, Out> {
234    runtime: &'rt R,
235    work: W,
236    _result: core::marker::PhantomData<fn() -> Out>,
237}
238
239impl<'rt, R, W, Out> Blocking<'rt, R, W, Out>
240where
241    R: Runtime + ?Sized,
242    Out: Send + 'static,
243    W: FnOnce() -> Result<Out, ProximaError> + Send + 'static,
244{
245    /// Hand the work to the background pool. The `Box<dyn Any>` round trip the
246    /// object-safe trait requires is paid here, once, so the caller gets `Out`.
247    pub fn spawn(self) -> impl Future<Output = Result<Out, ProximaError>> + Send {
248        let handle: BackgroundHandle<Box<dyn Any + Send>> =
249            self.runtime.spawn_background_blocking(Box::new(move || {
250                (self.work)().map(|value| Box::new(value) as Box<dyn Any + Send>)
251            }));
252        async move {
253            let boxed = handle.await?;
254            // the closure above is the only producer, so the concrete type is
255            // known; a downcast failure would mean the pool swapped the payload.
256            boxed
257                .downcast::<Out>()
258                .map(|value| *value)
259                .map_err(|_| ProximaError::Config("background payload type changed".into()))
260        }
261    }
262}
263
264#[cfg(test)]
265#[allow(clippy::unwrap_used, clippy::expect_used)]
266mod tests {
267    use alloc::sync::Arc;
268    use core::sync::atomic::{AtomicUsize, Ordering};
269
270    use super::*;
271
272    /// no `futures` dependency: this crate does not carry one outside
273    /// `--all-features`, and the stub's background impl resolves on the first
274    /// poll, so a bare loop is sufficient and cannot spin.
275    fn block_on<F: Future>(future: F) -> F::Output {
276        let mut future = core::pin::pin!(future);
277        let waker = core::task::Waker::noop();
278        let mut cx = core::task::Context::from_waker(waker);
279        loop {
280            if let core::task::Poll::Ready(value) = future.as_mut().poll(&mut cx) {
281                return value;
282            }
283        }
284    }
285
286    #[derive(Default)]
287    struct StubRuntime {
288        on_core: AtomicUsize,
289        background: AtomicUsize,
290        /// how many leading `spawn_on_core` calls report a full inbox, so a
291        /// test can force a candidate to be skipped rather than assuming it is.
292        full_for_first: AtomicUsize,
293    }
294
295    impl Runtime for StubRuntime {
296        fn spawn_on_current_core(
297            &self,
298            _f: core::pin::Pin<Box<dyn Future<Output = ()> + 'static>>,
299        ) {
300        }
301
302        fn spawn_on_core(
303            &self,
304            _core_id: CoreId,
305            _future: core::pin::Pin<Box<dyn Future<Output = ()> + Send + 'static>>,
306        ) -> Result<(), SpawnError> {
307            let attempt = self.on_core.fetch_add(1, Ordering::Relaxed);
308            if attempt < self.full_for_first.load(Ordering::Relaxed) {
309                return Err(SpawnError::InboxFull);
310            }
311            Ok(())
312        }
313
314        fn spawn_factory_on_core(
315            &self,
316            _core_id: CoreId,
317            _factory: Box<
318                dyn FnOnce() -> core::pin::Pin<Box<dyn Future<Output = ()> + 'static>> + Send,
319            >,
320        ) -> Result<(), SpawnError> {
321            Ok(())
322        }
323
324        fn spawn_background_blocking(
325            &self,
326            work: Box<dyn FnOnce() -> Result<Box<dyn Any + Send>, ProximaError> + Send>,
327        ) -> BackgroundHandle<Box<dyn Any + Send>> {
328            self.background.fetch_add(1, Ordering::Relaxed);
329            let result = work();
330            Box::pin(async move { result })
331        }
332
333        fn timer_at(
334            &self,
335            _deadline: std::time::Instant,
336        ) -> core::pin::Pin<Box<dyn Future<Output = ()>>> {
337            Box::pin(async {})
338        }
339
340        fn num_cores(&self) -> usize {
341            1
342        }
343
344        fn current_core(&self) -> CoreId {
345            CoreId(0)
346        }
347    }
348
349    /// The bug this guards: `Self: Sized` on the builder entry points made them
350    /// unreachable from `Arc<dyn Runtime>` — which is the form every storage
351    /// site in the workspace actually holds.
352    #[test]
353    fn builder_is_reachable_through_a_trait_object() {
354        let stub = Arc::new(StubRuntime::default());
355        let erased: Arc<dyn Runtime> = stub.clone();
356
357        erased.core(CoreId(0)).spawn(async {}).expect("chosen core");
358        assert_eq!(stub.on_core.load(Ordering::Relaxed), 1);
359    }
360
361    /// `AnyOther` must never resolve to the calling core -- that is its whole
362    /// meaning. Ordering starts at current+1 so repeated calls spread.
363    #[test]
364    fn any_other_excludes_the_current_core() {
365        let candidates = Placement::AnyOther.candidates(CoreId(2), 4);
366        assert_eq!(candidates, alloc::vec![CoreId(3), CoreId(0), CoreId(1)]);
367        assert!(!candidates.contains(&CoreId(2)));
368    }
369
370    #[test]
371    fn any_includes_the_current_core_last_in_wrap_order() {
372        assert_eq!(
373            Placement::Any.candidates(CoreId(2), 4),
374            alloc::vec![CoreId(2), CoreId(3), CoreId(0), CoreId(1)]
375        );
376    }
377
378    /// On a single-core runtime `AnyOther` has no candidate at all. It must say
379    /// so rather than silently falling back to the current core.
380    #[test]
381    fn any_other_on_one_core_yields_no_candidate() {
382        assert!(Placement::AnyOther.candidates(CoreId(0), 1).is_empty());
383
384        let stub = Arc::new(StubRuntime::default());
385        let erased: Arc<dyn Runtime> = stub.clone();
386        let outcome = erased.core(Placement::AnyOther).spawn(async {});
387
388        assert_eq!(outcome, Err(SpawnError::Disconnected));
389        assert_eq!(stub.on_core.load(Ordering::Relaxed), 0);
390    }
391
392    #[test]
393    fn a_bare_index_is_a_specific_core() {
394        assert_eq!(Placement::from(3usize), Placement::Only(CoreId(3)));
395    }
396
397    /// arrays and vecs, of bare indices or of `CoreId`, all reach `AnyOf` --
398    /// the caller should not have to build the collection the enum happens to
399    /// hold.
400    /// the literal form, unannotated -- integer literals must land on CoreId
401    /// without the caller writing `1usize` or `CoreId(1)`.
402    #[test]
403    fn a_bare_literal_array_is_a_candidate_list() {
404        let stub = Arc::new(StubRuntime::default());
405        let erased: Arc<dyn Runtime> = stub.clone();
406
407        erased
408            .core([1, 2, 3])
409            .spawn(async {})
410            .expect("first candidate");
411        assert_eq!(stub.on_core.load(Ordering::Relaxed), 1);
412    }
413
414    #[test]
415    fn arrays_and_vecs_of_either_element_type_become_candidate_lists() {
416        let expected = Placement::AnyOf(alloc::vec![CoreId(1), CoreId(2)]);
417
418        assert_eq!(Placement::from([1usize, 2]), expected);
419        assert_eq!(Placement::from([CoreId(1), CoreId(2)]), expected);
420        assert_eq!(Placement::from(alloc::vec![1usize, 2]), expected);
421        assert_eq!(Placement::from(alloc::vec![CoreId(1), CoreId(2)]), expected);
422    }
423
424    /// the behaviour the ordering tests could not reach: a full core must be
425    /// skipped and the next candidate tried, with the factory rebuilding the
426    /// future each time.
427    #[test]
428    fn a_full_core_falls_through_to_the_next_candidate() {
429        let stub = Arc::new(StubRuntime::default());
430        stub.full_for_first.store(2, Ordering::Relaxed);
431        let erased: Arc<dyn Runtime> = stub.clone();
432
433        let outcome = erased
434            .core([0, 1, 2])
435            .spawn_with(|| async {}, |_, err| *err == SpawnError::InboxFull);
436
437        assert_eq!(outcome, Ok(CoreId(2)), "reports which core took it");
438        assert_eq!(
439            stub.on_core.load(Ordering::Relaxed),
440            3,
441            "two full, third accepted"
442        );
443    }
444
445    /// the caller owns the policy: declining on the first error stops, even
446    /// though candidates remain and the error is the transient one.
447    #[test]
448    fn a_decline_stops_before_the_remaining_candidates() {
449        let stub = Arc::new(StubRuntime::default());
450        stub.full_for_first.store(3, Ordering::Relaxed);
451        let erased: Arc<dyn Runtime> = stub.clone();
452
453        let outcome = erased.core([0, 1, 2]).spawn_with(|| async {}, |_, _| false);
454
455        assert_eq!(
456            outcome,
457            Err(Exhausted {
458                attempts: 1,
459                last: Some(SpawnError::InboxFull)
460            })
461        );
462        assert_eq!(
463            stub.on_core.load(Ordering::Relaxed),
464            1,
465            "stopped after the first"
466        );
467    }
468
469    /// zero candidates is not a failed spawn -- nothing was attempted, so
470    /// there is no `SpawnError` to report. Previously this returned
471    /// `Disconnected`, which claimed a core had shut down.
472    #[test]
473    fn no_candidates_reports_zero_attempts_and_no_cause() {
474        let stub = Arc::new(StubRuntime::default());
475        let erased: Arc<dyn Runtime> = stub.clone();
476
477        let outcome = erased
478            .core(Placement::AnyOther)
479            .spawn_with(|| async {}, |_, _| true);
480
481        assert_eq!(
482            outcome,
483            Err(Exhausted {
484                attempts: 0,
485                last: None
486            })
487        );
488        assert_eq!(stub.on_core.load(Ordering::Relaxed), 0);
489    }
490
491    /// exhausting every candidate is distinguishable from one core being full,
492    /// which `Err(last_error)` could not express.
493    #[test]
494    fn every_candidate_full_reports_exhaustion_not_the_last_error() {
495        let stub = Arc::new(StubRuntime::default());
496        stub.full_for_first.store(9, Ordering::Relaxed);
497        let erased: Arc<dyn Runtime> = stub.clone();
498
499        let outcome = erased.core([0, 1, 2]).spawn_with(|| async {}, |_, _| true);
500
501        assert_eq!(
502            outcome,
503            Err(Exhausted {
504                attempts: 3,
505                last: Some(SpawnError::InboxFull)
506            })
507        );
508    }
509
510    #[test]
511    fn blocking_returns_the_concrete_type_not_an_any_box() {
512        let stub = Arc::new(StubRuntime::default());
513        let erased: Arc<dyn Runtime> = stub.clone();
514
515        let fut = erased.blocking(|| Ok(7u32)).spawn();
516        let value = block_on(fut).expect("background work");
517
518        assert_eq!(value, 7u32);
519        assert_eq!(stub.background.load(Ordering::Relaxed), 1);
520    }
521}