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}