Skip to main content

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}