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}