gpu_handle_types/wait_thread.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Waiter-thread infrastructure behind `async` waits on primitives that
4//! only offer a blocking wait.
5//!
6//! The fast-path yield-spin inside [`SyncWaiter::wait_async`] catches the
7//! sub-millisecond signal-already-here case with 64 cooperative yields.
8//! Anything past that bounded spin must hand off to a **permanently-
9//! attached** thread that issues the **native blocking-with-timeout**
10//! primitive on 10 ms slices — without `tokio::spawn_blocking`, without
11//! per-runtime timers, without per-operation thread spawn.
12//!
13//! ## Components
14//!
15//! - [`WaiterThread`] — owns the request channel + the OS thread.
16//! Typically one instance per backend kind (lazy-initialised via
17//! [`OnceLock`] inside each backend's waiter module); per-instance
18//! ownership is supported when callers want a deterministic
19//! shutdown via `Drop`.
20//! - [`SliceFn`] — boxed closure each per-backend `wait_async`
21//! override builds at handoff time. The waiter thread calls it
22//! repeatedly with up to 10 ms of slice budget; it returns
23//! [`SliceOutcome::Signaled`] / `TimedOut` / `Failed`.
24//! - [`BackendWaitFuture`] — the future returned to the executor.
25//! Its [`Drop`] flips a cancellation flag the waiter thread observes
26//! at slice boundaries; cancellation latency is therefore ≤ one
27//! slice (10 ms) — sized to fit inside a 60 fps frame budget.
28//! - [`run_hybrid_wait`] — composes the fast-path yield-spin + the
29//! waiter-thread fallback (thread handoff). Backends with a native blocking primitive call
30//! this from their `wait_async` override and pass the slice
31//! closure.
32//!
33//! ## Why a per-process (per-backend-kind) static thread
34//!
35//! The waiter thread is **stateless** — every per-instance datum (device
36//! handle, semaphore, fence, …) lives inside the [`SliceFn`] closure — so
37//! one thread per backend kind serves every waiter of that kind. A second
38//! per-instance thread would spend its life parked on `recv()` doing no
39//! work, and a thread per `SyncWaiter` would spawn thousands per second on
40//! a steady stream of frames.
41//!
42//! The trade-off: with a static singleton the thread is not joined on
43//! `Drop` of any individual backend; it lives until process exit and
44//! is reaped by the OS. The `Drop`-time join contract
45//! still holds when callers construct a [`WaiterThread`] explicitly
46//! and own it from a parent registry struct (the type supports that
47//! shape — `new` + `Drop` join cleanly), which is how this crate's
48//! tests exercise the lifecycle.
49
50use std::future::Future;
51use std::pin::Pin;
52use std::sync::atomic::{AtomicBool, Ordering};
53// mpsc channel + join handle back the native waiter thread; wasm has none.
54#[cfg(not(target_family = "wasm"))]
55use std::sync::mpsc;
56use std::sync::{Arc, Mutex};
57use std::task::{Context, Poll, Waker};
58#[cfg(not(target_family = "wasm"))]
59use std::thread::JoinHandle;
60use std::time::{Duration, Instant};
61
62use crate::Error;
63
64/// Per-slice native blocking-with-timeout budget. Sized to:
65///
66/// - Stay below the 60 fps frame budget (16.6 ms) so a future
67/// dropped at the frame boundary releases its waiter slot inside
68/// the same frame.
69/// - Be coarse enough that the syscall overhead per slice is
70/// amortised even on long waits — at 5 s of accumulated wait, the
71/// thread re-enters the kernel ~500 times, not 5_000.
72/// - Bound cancellation + deadline-exceeded latency to a single
73/// slice.
74///
75/// Tunable as a `Duration` rather than a feature so the value can be
76/// re-derived in tests without rebuilding the crate.
77pub const WAITER_SLICE: Duration = Duration::from_millis(10);
78
79/// Result of one slice-bounded native wait. Returned by the
80/// [`SliceFn`] closure each per-backend `wait_async` override
81/// supplies.
82#[derive(Debug)]
83pub enum SliceOutcome {
84 /// The primitive reached its target value within the slice.
85 /// Waiter thread resolves the future with `Ok(())`.
86 Signaled,
87 /// The slice's native timeout elapsed without signalling. Waiter
88 /// thread re-issues a fresh slice (or transitions to
89 /// `Err(Timeout)` if the per-request `deadline` has now passed).
90 TimedOut,
91 /// Driver-level failure (`VK_ERROR_DEVICE_LOST`,
92 /// `DXGI_ERROR_DEVICE_REMOVED`, `CL_INVALID_EVENT`, …). Waiter
93 /// thread resolves the future with this error directly.
94 Failed(Error),
95}
96
97/// Boxed closure each per-backend `wait_async` override builds at
98/// handoff time. The waiter thread calls it on its own thread with
99/// the current slice budget; the closure must:
100///
101/// - Issue the native blocking-with-timeout primitive
102/// (`vkWaitSemaphores`, `WaitForSingleObject`,
103/// `MTLSharedEvent::waitUntilSignaledValue:timeoutMS:`,
104/// `clWaitForEvents`, `device.poll(Wait { … })`, …) with the slice
105/// budget.
106///
107/// - Return one of the three [`SliceOutcome`] variants.
108///
109/// `Send` is required because the closure runs on the waiter thread,
110/// not on the executor's poll thread. `'static` because the slice
111/// closure is consumed asynchronously and outlives the `await`
112/// position that built it.
113// `Send` off wasm (the slice runs on the waiter thread). On wasm there is
114// no waiter thread — `enqueue` reports `NotSupported` — and the
115// slice closure may close over thread-affine `wgpu` handles, so the bound
116// is dropped. An explicit `+ Send` on a `dyn FnMut` cannot be spelled with
117// a non-auto marker trait, so the alias is cfg-split directly.
118#[cfg(not(target_family = "wasm"))]
119pub type SliceFn = Box<dyn FnMut(Duration) -> SliceOutcome + Send + 'static>;
120#[cfg(target_family = "wasm")]
121pub type SliceFn = Box<dyn FnMut(Duration) -> SliceOutcome + 'static>;
122
123/// Shared state between [`BackendWaitFuture`] (on the executor) and
124/// the waiter thread.
125struct WaitCompletion {
126 result: Option<Result<(), Error>>,
127 waker: Option<Waker>,
128}
129
130// Native-only: the request that crosses the channel to the waiter thread.
131// wasm has no waiter thread, so this is never constructed there.
132#[cfg(not(target_family = "wasm"))]
133struct WaitRequest {
134 slice_fn: SliceFn,
135 deadline: Option<Instant>,
136 cancelled: Arc<AtomicBool>,
137 completion: Arc<Mutex<WaitCompletion>>,
138}
139
140/// Permanently-attached waiter thread + request channel.
141///
142/// Cheap to construct (one `mpsc::channel` + one `thread::spawn`).
143/// Joined cleanly on `Drop`: the channel sender is dropped, the
144/// thread's `recv()` returns `Err`, the loop exits, and the join
145/// handle is consumed.
146///
147/// Every request that is in flight or still queued when `Drop` runs is
148/// resolved with [`Error::Cancelled`] before the thread exits.
149/// [`BackendWaitFuture`] borrows nothing from this type, so a caller may
150/// hold one across the drop; without that resolution its `await` would
151/// pend forever.
152///
153/// Per-instance ownership is supported, but the usual shape is a
154/// per-backend-kind static singleton in each backend's waiter module.
155/// See module-level docs.
156pub struct WaiterThread {
157 // Native-only: the channel + join handle to the waiter thread. On wasm
158 // these are absent so `WaiterThread` stays `Send + Sync` (a
159 // `Sender<WaitRequest>` carrying a `!Send` `SliceFn` would otherwise be
160 // `!Sync`, breaking the process-`static` singletons that hold it).
161 #[cfg(not(target_family = "wasm"))]
162 sender: Option<mpsc::Sender<WaitRequest>>,
163 #[cfg(not(target_family = "wasm"))]
164 join: Option<JoinHandle<()>>,
165 /// Thread-wide shutdown flag observed at slice boundaries.
166 /// Flipped in `Drop` so the thread exits even mid-request — closing
167 /// the channel alone wouldn't suffice when a long-running request is
168 /// in flight (the slice loop never returns to `recv()`).
169 shutdown: Arc<AtomicBool>,
170}
171
172impl std::fmt::Debug for WaiterThread {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 #[cfg(not(target_family = "wasm"))]
175 let alive = self.sender.is_some();
176 // wasm has no waiter thread — never "alive".
177 #[cfg(target_family = "wasm")]
178 let alive = false;
179 f.debug_struct("WaiterThread").field("alive", &alive).finish()
180 }
181}
182
183impl WaiterThread {
184 /// Spawn a new waiter thread. `name` is appended to `"gpu-wait-"` for
185 /// the thread name visible to debuggers and `ps -L` /
186 /// `Process Explorer`. Pick a short, stable backend-identifying string
187 /// (`"vulkan"`, `"wgpu"`, `"d3d12"`, `"metal"`, `"opencl"`, …) —
188 /// Linux truncates thread names to 15 bytes.
189 pub fn new(name: &str) -> Self {
190 // wasm has no OS threads. Construct an inert `WaiterThread`
191 // (`sender = None`): `enqueue` then takes its existing
192 // "already shut down" branch and reports `NotSupported`. Blocking
193 // waits are unavailable on wasm — callers use a non-blocking
194 // path; the `run_hybrid_wait` fast-path spin still runs.
195 #[cfg(target_family = "wasm")]
196 {
197 let _ = name;
198 Self { shutdown: Arc::new(AtomicBool::new(false)) }
199 }
200 #[cfg(not(target_family = "wasm"))]
201 {
202 let (tx, rx) = mpsc::channel::<WaitRequest>();
203 let shutdown = Arc::new(AtomicBool::new(false));
204 let shutdown_thread = shutdown.clone();
205 let join = std::thread::Builder::new()
206 .name(format!("gpu-wait-{name}"))
207 .spawn(move || waiter_loop(&rx, &shutdown_thread))
208 .expect("gpu-handle-types: failed to spawn waiter thread");
209 Self { sender: Some(tx), join: Some(join), shutdown }
210 }
211 }
212
213 /// Enqueue a wait request. Returns a [`BackendWaitFuture`] the
214 /// executor can `.await`; `Drop` of the future signals
215 /// cancellation to the waiter thread.
216 ///
217 /// `deadline = None` means "wait forever" — the slice loop never
218 /// transitions to `Err(Timeout)` and only exits on
219 /// `Signaled` / `Failed` / `cancelled`.
220 #[cfg_attr(target_family = "wasm", allow(unused_variables, clippy::needless_pass_by_value))]
221 pub fn enqueue(&self, slice_fn: SliceFn, deadline: Option<Instant>) -> BackendWaitFuture {
222 let cancelled = Arc::new(AtomicBool::new(false));
223 let completion = Arc::new(Mutex::new(WaitCompletion { result: None, waker: None }));
224 // wasm: no waiter thread — blocking waits are unavailable.
225 // Resolve the future immediately to `NotSupported`; callers use a
226 // non-blocking path. The `run_hybrid_wait` fast-path spin
227 // already handles the already-signaled case before reaching here.
228 #[cfg(target_family = "wasm")]
229 finish_completion(
230 &completion,
231 Err(Error::NotSupported(
232 "blocking waits are unavailable on wasm (no waiter thread); drive the async wait \
233 path instead"
234 .into(),
235 )),
236 );
237 #[cfg(not(target_family = "wasm"))]
238 {
239 let req = WaitRequest { slice_fn, deadline, cancelled: cancelled.clone(), completion: completion.clone() };
240 match self.sender.as_ref() {
241 Some(s) => {
242 if s.send(req).is_err() {
243 finish_completion(
244 &completion,
245 Err(Error::NotSupported("waiter thread died before request was accepted".into())),
246 );
247 }
248 }
249 None => {
250 finish_completion(&completion, Err(Error::NotSupported("waiter thread already shut down".into())));
251 }
252 }
253 }
254 BackendWaitFuture { completion, cancelled }
255 }
256}
257
258impl Drop for WaiterThread {
259 fn drop(&mut self) {
260 // Two-phase shutdown: (1) flip the thread-wide flag so the
261 // slice loop exits at the next slice boundary even if the
262 // request is still nominally in flight; (2) drop the sender
263 // so the outer `recv()` returns `Err` after the current
264 // request (if any) finishes. (wasm holds neither the sender nor
265 // the join handle — nothing to tear down beyond the flag.)
266 self.shutdown.store(true, Ordering::Release);
267 #[cfg(not(target_family = "wasm"))]
268 {
269 self.sender.take();
270 if let Some(h) = self.join.take() {
271 let _ = h.join();
272 }
273 }
274 }
275}
276
277#[cfg(not(target_family = "wasm"))]
278fn waiter_loop(rx: &mpsc::Receiver<WaitRequest>, shutdown: &AtomicBool) {
279 while let Ok(mut req) = rx.recv() {
280 if shutdown.load(Ordering::Acquire) {
281 abandon_on_shutdown(&req);
282 break;
283 }
284 // Set when the per-request loop exited because the thread is
285 // shutting down, so the outer loop stops pulling new work
286 // instead of blocking in `recv()` behind a sender that
287 // `WaiterThread::drop` may not have released yet.
288 let mut shutting_down = false;
289 loop {
290 // Thread-wide shutdown — `WaiterThread::drop` was called.
291 // Exit the per-request loop and the outer recv loop.
292 if shutdown.load(Ordering::Acquire) {
293 abandon_on_shutdown(&req);
294 shutting_down = true;
295 break;
296 }
297 // Cancellation — future dropped. Abandon the request;
298 // the native slice just issued (if any) has already
299 // returned, and the closure may keep state across
300 // returns that dropping it mid-slice would corrupt.
301 // Slice granularity (10 ms) is the cancellation latency
302 // bound by design.
303 //
304 // No `finish_completion` here, deliberately: the only way
305 // this flag is set is [`BackendWaitFuture::drop`], so there
306 // is no future left to resolve. Contrast the shutdown arm
307 // above, where the future is typically still alive.
308 if req.cancelled.load(Ordering::Acquire) {
309 break;
310 }
311 // Deadline check.
312 let remaining = match req.deadline {
313 Some(d) => match d.checked_duration_since(Instant::now()) {
314 Some(r) => r,
315 None => {
316 finish_completion(&req.completion, Err(Error::Timeout));
317 break;
318 }
319 },
320 None => Duration::MAX,
321 };
322 let slice = std::cmp::min(WAITER_SLICE, remaining);
323 match (req.slice_fn)(slice) {
324 SliceOutcome::Signaled => {
325 finish_completion(&req.completion, Ok(()));
326 break;
327 }
328 SliceOutcome::TimedOut => continue,
329 SliceOutcome::Failed(e) => {
330 finish_completion(&req.completion, Err(e));
331 break;
332 }
333 }
334 }
335 if shutting_down {
336 break;
337 }
338 }
339 // Requests still queued when the thread stops are resolved too, for
340 // the same reason: their futures are alive and nothing else will
341 // ever complete them. The sender has been dropped (or is about to
342 // be) by `WaiterThread::drop`, so this drains and terminates.
343 while let Ok(req) = rx.try_recv() {
344 abandon_on_shutdown(&req);
345 }
346}
347
348/// Resolve a request the waiter thread is giving up on because the
349/// thread itself is being torn down.
350///
351/// This is **not** optional bookkeeping. [`BackendWaitFuture`] borrows
352/// nothing from [`WaiterThread`] — it holds only the shared completion
353/// cell — so `let f = t.enqueue(..); drop(t); f.await` is well-typed and
354/// a caller may legitimately outlive the thread it enqueued on. Leaving
355/// the completion unresolved makes that `await` pend forever with no
356/// waker left in existence to rescue it.
357///
358/// [`Error::Cancelled`] rather than a failure kind: tearing the waiter
359/// thread down is a caller-initiated stop, exactly the "not a failure,
360/// the operation was asked to stop" case that
361/// [`crate::ErrorKind::Cancelled`] exists to classify. Callers void it
362/// instead of surfacing a fault.
363#[cfg(not(target_family = "wasm"))]
364fn abandon_on_shutdown(req: &WaitRequest) {
365 finish_completion(&req.completion, Err(Error::Cancelled));
366}
367
368fn finish_completion(completion: &Arc<Mutex<WaitCompletion>>, result: Result<(), Error>) {
369 let mut c = completion.lock().expect("WaitCompletion mutex poisoned");
370 // Don't clobber an existing terminal outcome (the cancellation
371 // path lets the future drop without resolving, but a follow-up
372 // poll would still observe the result — keep the first one).
373 if c.result.is_none() {
374 c.result = Some(result);
375 }
376 let waker = c.waker.take();
377 drop(c);
378 if let Some(w) = waker {
379 w.wake();
380 }
381}
382
383/// Future returned by [`WaiterThread::enqueue`]. Resolves when the
384/// waiter thread reports `Signaled` / `Failed` / `Err(Timeout)` on
385/// the deadline.
386///
387/// Drop semantics — `Drop` flips the cancellation flag the waiter
388/// thread observes at the next slice boundary. The native wait
389/// already in flight completes its current slice (up to
390/// [`WAITER_SLICE`]) before the slot is released; cancellation
391/// latency is bounded.
392pub struct BackendWaitFuture {
393 completion: Arc<Mutex<WaitCompletion>>,
394 cancelled: Arc<AtomicBool>,
395}
396
397impl std::fmt::Debug for BackendWaitFuture {
398 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399 f.debug_struct("BackendWaitFuture")
400 .field("cancelled", &self.cancelled.load(Ordering::Relaxed))
401 .field("resolved", &self.completion.lock().map(|c| c.result.is_some()).unwrap_or(false))
402 .finish()
403 }
404}
405
406impl Future for BackendWaitFuture {
407 type Output = Result<(), Error>;
408 fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
409 let mut c = self.completion.lock().expect("WaitCompletion mutex poisoned");
410 if let Some(r) = c.result.take() {
411 return Poll::Ready(r);
412 }
413 // Refresh the waker on every poll — executors that pass new
414 // wakers per re-poll (futures::executor) need this.
415 c.waker = Some(cx.waker().clone());
416 Poll::Pending
417 }
418}
419
420impl Drop for BackendWaitFuture {
421 fn drop(&mut self) {
422 self.cancelled.store(true, Ordering::Release);
423 }
424}
425
426/// Compose the fast-path yield-spin (bounded) + the waiter-thread
427/// fallback (thread handoff) into the canonical hybrid `wait_async` body.
428///
429/// Per-backend `SyncWaiter::wait_async` overrides call this with:
430/// - `is_signaled`: closure issuing the backend's non-blocking probe
431/// (`vkGetSemaphoreCounterValue`, `GetCompletedValue`,
432/// `signaledValue() >= value`, `clGetEventInfo`, `device.poll`
433/// with `Duration::ZERO`, …). Runs on the executor thread during
434/// the fast-path yield-spin.
435/// - `waiter_thread`: per-backend-kind [`WaiterThread`] reference.
436/// - `timeout`: absolute timeout from the caller.
437/// - `make_slice_fn`: builder for the [`SliceFn`] consumed on the
438/// waiter-thread fallback. Lazy so backends that resolve in the
439/// fast-path yield-spin skip closure construction entirely.
440///
441/// Returns:
442/// - `Ok(())` on signal,
443/// - `Err(Error::Timeout)` on deadline,
444/// - `Err(Error::DeviceLost { .. })` / `Err(Error::NotSupported(_.into()))`
445/// on driver-level failure surfaced by the slice closure.
446pub async fn run_hybrid_wait<F, M>(
447 is_signaled: F,
448 waiter_thread: &WaiterThread,
449 timeout: Duration,
450 make_slice_fn: M,
451) -> Result<(), Error>
452where
453 // `MaybeSend` = `Send` off wasm. On wasm the closures
454 // may capture thread-affine `wgpu` handles; the fast-path spin loop
455 // still runs on the executor, and the waiter-thread fallback reports
456 // `NotSupported`.
457 F: Fn() -> Result<bool, Error> + crate::MaybeSend,
458 M: FnOnce() -> SliceFn + crate::MaybeSend,
459{
460 // Fast-path yield-spin — bounded yield-poll. Iteration count, not wall-clock
461 // (pollster's re-poll-on-wake collapses wall-clock caps to a
462 // CPU burn here).
463 const SPIN_ITERATIONS: usize = 64;
464 // `None` iff `timeout == Duration::MAX` (the wait-forever sentinel, which
465 // `enqueue` also reads as "no deadline"); every finite timeout gets a bounded,
466 // always-representable deadline, so an `Instant + Duration` overflow cannot
467 // masquerade as "wait forever".
468 let deadline = crate::wait_deadline(timeout);
469 for _ in 0..SPIN_ITERATIONS {
470 match is_signaled() {
471 Ok(true) => return Ok(()),
472 Ok(false) => {}
473 Err(e) => return Err(e),
474 }
475 if let Some(d) = deadline
476 && Instant::now() >= d
477 {
478 return Err(Error::Timeout);
479 }
480 crate::yield_once().await;
481 }
482 // Waiter-thread fallback — hand off to the waiter thread.
483 let slice_fn = make_slice_fn();
484 waiter_thread.enqueue(slice_fn, deadline).await
485}