Skip to main content

gpu_handle_types/
wait_thread.rs

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