Skip to main content

viewport_lib/runtime/
jobs.rs

1//! Async job handoff types for runtime plugins.
2//!
3//! [`JobSlot`] and [`JobSender`] provide a typed result slot shared between a
4//! background thread and the runtime frame loop. A plugin creates a linked pair
5//! with [`JobSlot::new`], moves the [`JobSender`] to a background thread, and
6//! polls the [`JobSlot`] in `collect` calls until a result arrives.
7//!
8//! # Usage pattern
9//!
10//! ```rust,ignore
11//! use viewport_lib::runtime::{RuntimePlugin, RuntimeStepContext};
12//! use viewport_lib::runtime::jobs::{JobSlot, JobPoll};
13//! use viewport_lib::runtime::plugin::phase;
14//!
15//! struct LoaderPlugin {
16//!     slot: JobSlot<Vec<u8>>,
17//! }
18//!
19//! impl RuntimePlugin for LoaderPlugin {
20//!     fn priority(&self) -> i32 { phase::PREPARE }
21//!
22//!     fn submit(&mut self, _ctx: &RuntimeStepContext) {
23//!         // Start a new job only when the slot is idle.
24//!         if self.slot.is_empty() {
25//!             let (new_slot, sender) = JobSlot::new();
26//!             self.slot = new_slot;
27//!             std::thread::spawn(move || {
28//!                 // ... load data ...
29//!                 sender.complete(vec![1, 2, 3]);
30//!             });
31//!         }
32//!     }
33//!
34//!     fn collect(&mut self, ctx: &mut RuntimeStepContext) {
35//!         match self.slot.take() {
36//!             JobPoll::Ready(data) => {
37//!                 ctx.resources.insert(LoadedData { data });
38//!             }
39//!             JobPoll::Failed(msg) => {
40//!                 tracing::warn!("load failed: {msg}");
41//!             }
42//!             _ => {}
43//!         }
44//!     }
45//!
46//!     fn step(&mut self, _ctx: &mut RuntimeStepContext) {}
47//! }
48//! ```
49//!
50//! # When to use submit/collect vs resources
51//!
52//! - Use `submit` + `collect` when a plugin does out-of-frame work (disk IO, decompression,
53//!   heavy CPU) and needs the result one or more frames later.
54//! - Use `collect` alone when the work is light enough to do in-frame after the step loop.
55//! - Store the integrated result in [`super::resources::RuntimeResources`] so other plugins
56//!   can read it. The `JobSlot` stays in the plugin that owns the job; downstream plugins
57//!   read from resources, not from the slot directly.
58//! - To replace stale data, remove the old resource and insert the new one in the same
59//!   `collect` call. Other plugins that run `collect` later in priority order will see
60//!   the updated value immediately.
61//!
62//! # Cross-plugin jobs
63//!
64//! If more than one plugin needs to check or fulfill the same job, store the
65//! [`JobSlot`] in [`super::resources::RuntimeResources`]. The owning plugin
66//! inserts it; other plugins read it via `resources.get::<JobSlot<T>>()`.
67
68use std::sync::{Arc, Mutex};
69
70/// Result of polling a [`JobSlot`].
71pub enum JobPoll<T> {
72    /// No job has been started. The slot is idle.
73    Empty,
74    /// A job is running and has not finished yet.
75    Pending,
76    /// The job completed successfully. The slot is reset to empty.
77    Ready(T),
78    /// The job failed. The slot is reset to empty.
79    Failed(String),
80    /// The job was cancelled (or the sender was dropped). The slot is reset to empty.
81    Cancelled,
82}
83
84#[derive(Debug, PartialEq)]
85enum SlotState<T> {
86    Empty,
87    Pending,
88    Ready(T),
89    Failed(String),
90    Cancelled,
91}
92
93/// Typed result slot for handing off async work to the runtime frame loop.
94///
95/// Create a linked pair with [`JobSlot::new`]. The plugin holds the slot; the
96/// [`JobSender`] is sent to a background thread. Poll the slot each frame in
97/// `collect` until a result arrives.
98///
99/// `JobSlot<T>` is `Send + 'static` when `T: Send + 'static`, so it can be
100/// stored in [`super::resources::RuntimeResources`] for cross-plugin access.
101pub struct JobSlot<T: Send + 'static> {
102    state: Arc<Mutex<SlotState<T>>>,
103}
104
105// Manual Send/Sync impls: Arc<Mutex<SlotState<T>>> is Send + Sync when T: Send.
106// This is already guaranteed by the trait bounds, but spelled out for clarity.
107unsafe impl<T: Send + 'static> Send for JobSlot<T> {}
108unsafe impl<T: Send + 'static> Sync for JobSlot<T> {}
109
110impl<T: Send + 'static> Default for JobSlot<T> {
111    fn default() -> Self {
112        Self {
113            state: Arc::new(Mutex::new(SlotState::Empty)),
114        }
115    }
116}
117
118impl<T: Send + 'static> JobSlot<T> {
119    /// Create an idle slot with no job running.
120    pub fn empty() -> Self {
121        Self::default()
122    }
123
124    /// Create a linked (slot, sender) pair.
125    ///
126    /// The slot starts in the `Pending` state. The sender signals completion,
127    /// failure, or cancellation from a background thread.
128    pub fn new() -> (Self, JobSender<T>) {
129        let state = Arc::new(Mutex::new(SlotState::Pending));
130        let slot = Self {
131            state: state.clone(),
132        };
133        let sender = JobSender {
134            state,
135            consumed: false,
136        };
137        (slot, sender)
138    }
139
140    /// Poll the slot for a result.
141    ///
142    /// If the job has finished (`Ready`, `Failed`, or `Cancelled`), the result
143    /// is returned and the slot resets to empty, making it ready for a new job.
144    /// Returns `Pending` while the background thread is still running.
145    pub fn take(&mut self) -> JobPoll<T> {
146        let mut guard = self.state.lock().unwrap();
147        match &*guard {
148            SlotState::Empty => JobPoll::Empty,
149            SlotState::Pending => JobPoll::Pending,
150            SlotState::Ready(_) => {
151                let SlotState::Ready(v) = std::mem::replace(&mut *guard, SlotState::Empty) else {
152                    unreachable!()
153                };
154                JobPoll::Ready(v)
155            }
156            SlotState::Failed(_) => {
157                let SlotState::Failed(msg) = std::mem::replace(&mut *guard, SlotState::Empty)
158                else {
159                    unreachable!()
160                };
161                JobPoll::Failed(msg)
162            }
163            SlotState::Cancelled => {
164                *guard = SlotState::Empty;
165                JobPoll::Cancelled
166            }
167        }
168    }
169
170    /// Returns `true` if no job is running and no result is waiting.
171    pub fn is_empty(&self) -> bool {
172        matches!(*self.state.lock().unwrap(), SlotState::Empty)
173    }
174
175    /// Returns `true` if a job is running and has not yet finished.
176    pub fn is_pending(&self) -> bool {
177        matches!(*self.state.lock().unwrap(), SlotState::Pending)
178    }
179
180    /// Returns `true` if a result is ready to take.
181    pub fn is_ready(&self) -> bool {
182        matches!(*self.state.lock().unwrap(), SlotState::Ready(_))
183    }
184}
185
186/// Send end of a [`JobSlot`].
187///
188/// Created by [`JobSlot::new`]. Move this to a background thread and call
189/// [`complete`](Self::complete), [`fail`](Self::fail), or [`cancel`](Self::cancel)
190/// when work finishes. Dropping the sender without signaling transitions the
191/// slot to `Cancelled`.
192pub struct JobSender<T: Send + 'static> {
193    state: Arc<Mutex<SlotState<T>>>,
194    consumed: bool,
195}
196
197unsafe impl<T: Send + 'static> Send for JobSender<T> {}
198
199impl<T: Send + 'static> JobSender<T> {
200    /// Signal successful completion with `result`.
201    pub fn complete(mut self, result: T) {
202        *self.state.lock().unwrap() = SlotState::Ready(result);
203        self.consumed = true;
204    }
205
206    /// Signal failure with an error message.
207    pub fn fail(mut self, msg: impl Into<String>) {
208        *self.state.lock().unwrap() = SlotState::Failed(msg.into());
209        self.consumed = true;
210    }
211
212    /// Signal that the job was cancelled.
213    pub fn cancel(mut self) {
214        *self.state.lock().unwrap() = SlotState::Cancelled;
215        self.consumed = true;
216    }
217}
218
219impl<T: Send + 'static> Drop for JobSender<T> {
220    fn drop(&mut self) {
221        if !self.consumed {
222            *self.state.lock().unwrap() = SlotState::Cancelled;
223        }
224    }
225}