Skip to main content

tensor_wasm_wasi_gpu/
async_dispatch.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Async GPU dispatch & back-pressure.
5//!
6//! After the `launch` host function records a kernel launch (via a CUDA
7//! Event on real hardware), Wasmtime suspends the calling Wasm fiber by
8//! awaiting a [`DispatchFuture`]. The runtime is free to schedule other
9//! Wasm instances in the meantime. The future resolves when the CUDA Event
10//! synchronises, signaling kernel completion.
11//!
12//! On no-CUDA hosts the future resolves immediately (it represents work
13//! that "ran" only nominally), but the back-pressure machinery still
14//! applies — useful for unit-testing the rate-limit logic.
15
16use std::sync::atomic::{AtomicUsize, Ordering};
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20use tokio::sync::{OwnedSemaphorePermit, Semaphore, SemaphorePermit};
21
22use crate::abi::AbiError;
23
24/// Default maximum number of concurrent GPU operations across the process.
25/// Mirrors the plan's choice of "a few times the number of SMs" — tuned
26/// at startup to match the deployed hardware in S17.
27pub const DEFAULT_MAX_CONCURRENT_GPU_OPS: usize = 256;
28
29/// Window before the configured deadline during which the back-pressure
30/// path tightens: new acquires are rejected with
31/// [`BackPressureError::DeadlineNear`] so the in-flight cohort can
32/// drain without being further saturated by fresh launches. Picked at
33/// 50 ms — five default epoch ticks — which empirically lets a
34/// per-tile loop wind down (typical tile completion ≤ a few ms)
35/// without surrendering useful budget on the common path.
36///
37/// Kept distinct from the scheduler's own
38/// [`crate::scheduler::SUGGESTED_YIELD_THRESHOLD_MS`] (10 ms): that
39/// threshold biases the cooperative yield code; this one biases
40/// resource admission. The wider window for back-pressure gives the
41/// scheduler an additional 40 ms safety margin to land a STOP code
42/// before any new in-flight work is permitted.
43pub const DEADLINE_NEAR_WINDOW: Duration = Duration::from_millis(50);
44
45/// Errors returned by the deadline-aware back-pressure acquire path.
46///
47/// Distinct from [`AbiError`] so callers that want to discriminate
48/// "saturated forever" from "saturated because the per-instance
49/// deadline is approaching" can do so without having to inspect the
50/// surrounding context. Conversions to [`AbiError`] (for back-compat
51/// with the existing host-function return path) collapse both
52/// deadline variants onto [`AbiError::QuotaExceeded`] — the guest sees
53/// the same "no permits available" signal it already handles, and the
54/// richer variant survives in logs / metrics that bind directly to
55/// `BackPressureError`.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum BackPressureError {
58    /// The semaphore is saturated and (for the cap-0 sentinel) will
59    /// never release a permit. Equivalent to the historical
60    /// [`AbiError::QuotaExceeded`] surface — kept under that name so
61    /// the conversion path is unambiguous.
62    Saturated,
63    /// The configured per-invocation deadline is within
64    /// [`DEADLINE_NEAR_WINDOW`] of elapsing. In-flight permits are
65    /// allowed to complete; new acquires are refused so the cohort
66    /// drains under bounded budget.
67    DeadlineNear,
68    /// The configured per-invocation deadline has already elapsed.
69    /// All future acquires (including pending awaiters that have not
70    /// yet observed a permit) are rejected.
71    DeadlineElapsed,
72}
73
74impl BackPressureError {
75    /// Stable, human-readable name (used for log fields).
76    pub fn name(self) -> &'static str {
77        match self {
78            BackPressureError::Saturated => "saturated",
79            BackPressureError::DeadlineNear => "deadline_near",
80            BackPressureError::DeadlineElapsed => "deadline_elapsed",
81        }
82    }
83}
84
85impl From<BackPressureError> for AbiError {
86    fn from(e: BackPressureError) -> Self {
87        // Collapse onto QuotaExceeded — the wire shape every existing
88        // guest already knows. The richer variant survives in
89        // structured logs / metrics that bind directly to
90        // `BackPressureError`.
91        match e {
92            BackPressureError::Saturated
93            | BackPressureError::DeadlineNear
94            | BackPressureError::DeadlineElapsed => AbiError::QuotaExceeded,
95        }
96    }
97}
98
99/// A back-pressure semaphore plus a live-counter for observability.
100///
101/// The semaphore itself lives behind an `Arc<BackPressureInner>` so
102/// multiple `BackPressure` clones share the same permit pool — this is
103/// what makes the cap process-wide rather than per-instance. The
104/// optional per-call deadline lives **on the clone** (not inside the
105/// `Arc`) so two instances pulling from the same shared pool can each
106/// carry their own deadline without racing on a shared `Mutex`.
107#[derive(Clone)]
108pub struct BackPressure {
109    inner: Arc<BackPressureInner>,
110    /// Per-clone deadline used by the deadline-aware acquire path.
111    /// `None` means "no deadline configured" — the acquire path
112    /// behaves exactly as before. Installed via
113    /// [`BackPressure::with_deadline_hint`].
114    deadline: Option<Instant>,
115}
116
117struct BackPressureInner {
118    semaphore: Arc<Semaphore>,
119    active: AtomicUsize,
120    max_concurrent: usize,
121}
122
123impl BackPressure {
124    /// Construct with the default concurrency cap.
125    pub fn new() -> Self {
126        Self::with_cap(DEFAULT_MAX_CONCURRENT_GPU_OPS)
127    }
128
129    /// Construct with an explicit concurrency cap.
130    pub fn with_cap(max_concurrent: usize) -> Self {
131        Self {
132            inner: Arc::new(BackPressureInner {
133                semaphore: Arc::new(Semaphore::new(max_concurrent)),
134                active: AtomicUsize::new(0),
135                max_concurrent,
136            }),
137            deadline: None,
138        }
139    }
140
141    /// Attach a per-invocation deadline to this `BackPressure` clone.
142    ///
143    /// The deadline is consulted on every acquire (both `acquire` and
144    /// `acquire_borrowed`):
145    ///
146    /// - If `Instant::now() >= deadline`, the acquire returns
147    ///   [`BackPressureError::DeadlineElapsed`] without awaiting.
148    /// - If `Instant::now() >= deadline - DEADLINE_NEAR_WINDOW`, the
149    ///   acquire returns [`BackPressureError::DeadlineNear`] — the
150    ///   in-flight cohort is permitted to complete but no new permits
151    ///   are issued.
152    /// - Otherwise the acquire behaves exactly as before (`None`
153    ///   deadline = unchanged behaviour).
154    ///
155    /// Builder method: consumes `self` and returns a new clone with
156    /// the deadline installed. The underlying semaphore is shared via
157    /// `Arc` so two clones that pull from the same pool can each
158    /// carry their own deadline. Passing `None` is the documented
159    /// "no deadline" knob — equivalent to a fresh `BackPressure`
160    /// constructed via [`BackPressure::with_cap`].
161    ///
162    /// See [`crate::scheduler::SchedulerContext`] for the matching
163    /// cooperative-yield query path: the executor builds both from
164    /// the same `Instant` so the guest's `yield()` verdicts and the
165    /// host's acquire decisions agree on when the deadline trips.
166    pub fn with_deadline_hint(mut self, deadline: Option<Instant>) -> Self {
167        self.deadline = deadline;
168        self
169    }
170
171    /// Borrow the per-clone deadline, if any. Mirrors
172    /// [`BackPressure::with_deadline_hint`] — useful for tests and
173    /// observability paths that want to confirm the executor wired the
174    /// deadline through.
175    pub fn deadline_hint(&self) -> Option<Instant> {
176        self.deadline
177    }
178
179    /// Inspect the per-clone deadline and classify the current state.
180    /// Returns `Ok(())` when the acquire path should proceed,
181    /// `Err(DeadlineNear)` when in-flight may complete but new
182    /// acquires are refused, and `Err(DeadlineElapsed)` when all
183    /// acquires must be refused.
184    fn check_deadline(&self) -> Result<(), BackPressureError> {
185        let Some(d) = self.deadline else {
186            return Ok(());
187        };
188        let now = Instant::now();
189        if now >= d {
190            return Err(BackPressureError::DeadlineElapsed);
191        }
192        // `d.checked_duration_since(now)` is positive here (now < d);
193        // the remaining-window comparison reads more naturally that
194        // way than juggling Instant arithmetic.
195        let remaining = d.saturating_duration_since(now);
196        if remaining <= DEADLINE_NEAR_WINDOW {
197            return Err(BackPressureError::DeadlineNear);
198        }
199        Ok(())
200    }
201
202    /// Acquire one permit, awaiting back-pressure if necessary.
203    ///
204    /// Returns an *owned* permit (`'static`) suitable for callers that move
205    /// the permit across `tokio::spawn` boundaries (tests, benches, any
206    /// future fan-out path). Each call clones the inner `Arc<Semaphore>`.
207    /// On the non-spawning production hot path, prefer
208    /// [`BackPressure::acquire_borrowed`] which avoids that clone.
209    ///
210    /// Deadline-hint-unaware: this method returns an unconditional
211    /// permit and never refuses on deadline grounds. Callers that
212    /// need the deadline-aware rejection path must use
213    /// [`BackPressure::try_acquire_with_deadline`] or
214    /// [`BackPressure::acquire_borrowed`]; this method is kept on the
215    /// pre-deadline surface for tests / benches that pre-date the
216    /// deadline plumbing and would otherwise need a typed-error
217    /// rewrite. (The production `launch_impl_async` path uses
218    /// `acquire_borrowed`, which DOES honour the deadline.)
219    pub async fn acquire(&self) -> DispatchPermit {
220        let permit = self
221            .inner
222            .semaphore
223            .clone()
224            .acquire_owned()
225            .await
226            .expect("semaphore closed unexpectedly");
227        self.inner.active.fetch_add(1, Ordering::Relaxed);
228        DispatchPermit {
229            permit: Some(permit),
230            counter: self.inner.clone(),
231        }
232    }
233
234    /// Deadline-aware counterpart to [`BackPressure::acquire`] that
235    /// returns the typed [`BackPressureError`] on rejection.
236    ///
237    /// On the no-deadline path this behaves exactly like
238    /// [`BackPressure::acquire`] but with the typed error surface.
239    /// When a deadline is configured (via
240    /// [`BackPressure::with_deadline_hint`]):
241    ///
242    /// - Past the deadline: returns `Err(DeadlineElapsed)`.
243    /// - Within `DEADLINE_NEAR_WINDOW` of the deadline: returns
244    ///   `Err(DeadlineNear)` — in-flight acquires already issued are
245    ///   not affected (they hold their permits to completion); only
246    ///   *new* acquires are refused, allowing the cohort to drain.
247    /// - More than `DEADLINE_NEAR_WINDOW` away: behaves as
248    ///   [`BackPressure::acquire`] (awaits a permit).
249    pub async fn acquire_with_deadline(&self) -> Result<DispatchPermit, BackPressureError> {
250        // Pre-await deadline check: if we already know the acquire
251        // must be refused, do so without entering the semaphore
252        // queue. The post-await re-check below handles the case
253        // where the deadline trips WHILE waiting.
254        self.check_deadline()?;
255        let permit_fut = self.inner.semaphore.clone().acquire_owned();
256        // Wait for either the permit or the deadline — whichever
257        // fires first. Without a deadline configured we just await
258        // the permit directly.
259        let permit = if let Some(d) = self.deadline {
260            tokio::select! {
261                p = permit_fut => p.expect("semaphore closed unexpectedly"),
262                _ = tokio::time::sleep_until(d.into()) => {
263                    return Err(BackPressureError::DeadlineElapsed);
264                }
265            }
266        } else {
267            permit_fut.await.expect("semaphore closed unexpectedly")
268        };
269        // Re-check after the await: the deadline may have entered
270        // the NEAR window or even elapsed while we were queued for
271        // a permit. Drop the just-acquired permit on rejection so
272        // a queued cohort behind us still sees a fair release.
273        if let Err(e) = self.check_deadline() {
274            drop(permit);
275            return Err(e);
276        }
277        self.inner.active.fetch_add(1, Ordering::Relaxed);
278        Ok(DispatchPermit {
279            permit: Some(permit),
280            counter: self.inner.clone(),
281        })
282    }
283
284    /// Non-blocking variant of [`BackPressure::acquire_with_deadline`].
285    /// Returns `Err(Saturated)` when no permit is immediately
286    /// available *and* the deadline does not pre-empt the saturation
287    /// path with a more specific code.
288    pub fn try_acquire_with_deadline(&self) -> Result<DispatchPermit, BackPressureError> {
289        self.check_deadline()?;
290        let permit = self
291            .inner
292            .semaphore
293            .clone()
294            .try_acquire_owned()
295            .map_err(|_| BackPressureError::Saturated)?;
296        self.inner.active.fetch_add(1, Ordering::Relaxed);
297        Ok(DispatchPermit {
298            permit: Some(permit),
299            counter: self.inner.clone(),
300        })
301    }
302
303    /// Acquire one permit, awaiting back-pressure if necessary.
304    ///
305    /// Returns a *borrowed* permit whose lifetime is tied to `&self`. This
306    /// avoids the `Arc<Semaphore>` clone that [`BackPressure::acquire`]
307    /// performs and is the right choice for callers that hold the permit
308    /// only within the current async scope (no `tokio::spawn`). The
309    /// production `launch_impl_async` host-function path runs inside a
310    /// wasmtime async fiber that never spawns, so it uses this variant.
311    ///
312    /// # Cap-0 / saturated semantics
313    ///
314    /// A cap of `0` is a "no permits, ever" sentinel: the semaphore was
315    /// constructed with zero permits and none will ever be released
316    /// because nothing can acquire to release. Awaiting `Semaphore::acquire`
317    /// in that state parks the caller forever — a footgun, because guests
318    /// observing back-pressure should see `QuotaExceeded` rather than
319    /// hang. We therefore probe with `try_acquire` first: if it fails
320    /// AND the configured cap is `0`, we return [`AbiError::QuotaExceeded`]
321    /// rather than awaiting. For caps > 0 we fall through to the standard
322    /// async acquire (permits will eventually return as in-flight
323    /// dispatches drop their permits).
324    pub async fn acquire_borrowed(&self) -> Result<BorrowedDispatchPermit<'_>, AbiError> {
325        // Pre-await deadline check (T36 — cooperative deadlines).
326        // If the per-invocation deadline says the acquire must be
327        // refused, bail out *before* even probing the semaphore: a
328        // guest hammering `launch` past its deadline must not be
329        // able to drain in-flight permits by racing the rejection
330        // check.
331        if let Err(e) = self.check_deadline() {
332            return Err(e.into());
333        }
334        // Fast path / saturated-cap-0 guard: try a synchronous acquire
335        // first. On success we skip the async machinery entirely; on
336        // failure we check whether the cap is the cap-0 sentinel and, if
337        // so, surface `QuotaExceeded` instead of parking forever.
338        if let Ok(permit) = self.inner.semaphore.try_acquire() {
339            self.inner.active.fetch_add(1, Ordering::Relaxed);
340            return Ok(BorrowedDispatchPermit {
341                permit: Some(permit),
342                counter: &self.inner,
343            });
344        }
345        if self.inner.max_concurrent == 0 {
346            // Cap-0 semaphores never release a permit; awaiting would
347            // park the wasm fiber indefinitely. Return the saturated-
348            // back-pressure signal instead.
349            return Err(AbiError::QuotaExceeded);
350        }
351        // Race the semaphore acquire against the deadline (when
352        // configured). The first arm to resolve wins; the others are
353        // dropped. Without a deadline we just await the permit.
354        let permit = if let Some(d) = self.deadline {
355            tokio::select! {
356                p = self.inner.semaphore.acquire() => p.expect("semaphore closed unexpectedly"),
357                _ = tokio::time::sleep_until(d.into()) => {
358                    return Err(BackPressureError::DeadlineElapsed.into());
359                }
360            }
361        } else {
362            self.inner
363                .semaphore
364                .acquire()
365                .await
366                .expect("semaphore closed unexpectedly")
367        };
368        // Post-await re-check: the deadline may have crossed into
369        // the NEAR window (or elapsed) while we waited in the
370        // semaphore queue. Drop the freshly-acquired permit on
371        // rejection so a queued cohort behind us still sees fair
372        // release of the slot.
373        if let Err(e) = self.check_deadline() {
374            drop(permit);
375            return Err(e.into());
376        }
377        self.inner.active.fetch_add(1, Ordering::Relaxed);
378        Ok(BorrowedDispatchPermit {
379            permit: Some(permit),
380            counter: &self.inner,
381        })
382    }
383
384    /// Try to acquire a permit without awaiting. Returns `None` under load.
385    pub fn try_acquire(&self) -> Option<DispatchPermit> {
386        let permit = self.inner.semaphore.clone().try_acquire_owned().ok()?;
387        self.inner.active.fetch_add(1, Ordering::Relaxed);
388        Some(DispatchPermit {
389            permit: Some(permit),
390            counter: self.inner.clone(),
391        })
392    }
393
394    /// Current number of in-flight dispatches.
395    pub fn active(&self) -> usize {
396        self.inner.active.load(Ordering::Relaxed)
397    }
398
399    /// Maximum concurrent dispatches.
400    pub fn max_concurrent(&self) -> usize {
401        self.inner.max_concurrent
402    }
403}
404
405impl Default for BackPressure {
406    fn default() -> Self {
407        Self::new()
408    }
409}
410
411/// RAII permit returned by [`BackPressure::acquire`]. Dropping it releases
412/// the underlying semaphore slot and decrements the live counter.
413pub struct DispatchPermit {
414    permit: Option<OwnedSemaphorePermit>,
415    counter: Arc<BackPressureInner>,
416}
417
418impl std::fmt::Debug for DispatchPermit {
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        f.debug_struct("DispatchPermit")
421            .field("permit_held", &self.permit.is_some())
422            .finish()
423    }
424}
425
426impl Drop for DispatchPermit {
427    fn drop(&mut self) {
428        // SAFETY: the permit's own Drop releases the slot.
429        self.permit = None;
430        self.counter.active.fetch_sub(1, Ordering::Relaxed);
431    }
432}
433
434/// Borrowed counterpart to [`DispatchPermit`] returned by
435/// [`BackPressure::acquire_borrowed`]. Its lifetime is bound to the
436/// `&BackPressure` it was acquired from, so it cannot cross a
437/// `tokio::spawn` boundary — use [`DispatchPermit`] for that. In return,
438/// acquisition skips the `Arc<Semaphore>` clone the owned variant pays.
439///
440/// Drop semantics are identical to [`DispatchPermit`]: releasing the
441/// semaphore slot and decrementing the live-counter.
442pub struct BorrowedDispatchPermit<'a> {
443    permit: Option<SemaphorePermit<'a>>,
444    counter: &'a BackPressureInner,
445}
446
447impl std::fmt::Debug for BorrowedDispatchPermit<'_> {
448    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449        f.debug_struct("BorrowedDispatchPermit")
450            .field("issued", &self.permit.is_some())
451            .finish()
452    }
453}
454
455impl Drop for BorrowedDispatchPermit<'_> {
456    fn drop(&mut self) {
457        // SAFETY: the permit's own Drop releases the slot.
458        self.permit = None;
459        self.counter.active.fetch_sub(1, Ordering::Relaxed);
460    }
461}
462
463/// A future representing an in-flight GPU dispatch.
464///
465/// On the no-CUDA stub path this resolves immediately. On CUDA hosts the
466/// future polls a `cust::event::Event` recorded on the launch stream:
467/// `event.query()` returns `Ok(())` once the GPU has finished the
468/// associated work. Until then we re-schedule via the waker so the
469/// wasmtime fiber can continue to be suspended.
470///
471/// The future carries a `tracing::Span` captured at construction time
472/// (the active span belonging to whichever host function created it,
473/// typically `wasi_cuda.launch`). Every `poll` call enters that span,
474/// so any work — including the `cust::event::Event::query` poll path —
475/// is attributed to the originating dispatch in the trace tree even
476/// though `poll` is invoked from the Tokio runtime's reactor, not from
477/// inside the host function. Without this the dispatch's poll events
478/// would be parented to the runtime worker's empty context and would
479/// disappear from the distributed trace.
480pub struct DispatchFuture {
481    _permit: DispatchPermit,
482    /// Span entered on every poll so the future stays attached to the
483    /// dispatch trace context across runtime task switches. Holding a
484    /// `Span` (rather than an `EnteredSpan`) is cheap (it's a shallow
485    /// `Arc`-clone) and lets us re-enter on each poll without leaking
486    /// the guard.
487    dispatch_span: tracing::Span,
488    /// On CUDA builds: a recorded event whose completion signals kernel
489    /// done. We poll `event.query()` from the future. On no-CUDA builds
490    /// this field is absent and the future resolves on first poll.
491    #[cfg(feature = "cuda")]
492    event: Option<cust::event::Event>,
493    /// On CUDA builds: an in-flight short sleep used to yield the Tokio
494    /// worker between CUDA event polls. The sleep timer wakes us, so we
495    /// do not need to call `wake_by_ref` and avoid a busy-poll loop.
496    #[cfg(feature = "cuda")]
497    sleep: Option<std::pin::Pin<Box<tokio::time::Sleep>>>,
498}
499
500impl DispatchFuture {
501    /// Build a future bound to the given back-pressure permit. The future
502    /// resolves the next time it is polled (no-CUDA path) or once the
503    /// CUDA event has fired (CUDA path with `bind_event`).
504    pub fn ready(permit: DispatchPermit) -> Self {
505        Self {
506            _permit: permit,
507            dispatch_span: tracing::info_span!("wasi_cuda.dispatch"),
508            #[cfg(feature = "cuda")]
509            event: None,
510            #[cfg(feature = "cuda")]
511            sleep: None,
512        }
513    }
514
515    /// Attach a recorded CUDA event to this future (CUDA-only).
516    ///
517    /// After this call, `poll` will return `Pending` until `event.query()`
518    /// reports the work has completed. Without this call (the
519    /// [`DispatchFuture::ready`] path) the future still resolves
520    /// immediately, matching the no-CUDA semantics.
521    #[cfg(feature = "cuda")]
522    pub fn with_event(permit: DispatchPermit, event: cust::event::Event) -> Self {
523        Self {
524            _permit: permit,
525            dispatch_span: tracing::info_span!("wasi_cuda.dispatch"),
526            event: Some(event),
527            sleep: None,
528        }
529    }
530}
531
532impl std::future::Future for DispatchFuture {
533    type Output = ();
534    fn poll(
535        self: std::pin::Pin<&mut Self>,
536        #[cfg_attr(not(feature = "cuda"), allow(unused_variables))] cx: &mut std::task::Context<'_>,
537    ) -> std::task::Poll<()> {
538        // Enter the dispatch span for the duration of this poll so the
539        // trace stays attached to the originating `wasi_cuda.launch`
540        // span tree regardless of which Tokio worker is running us. The
541        // returned guard is dropped at the end of this function.
542        let _entered = self.dispatch_span.enter();
543
544        // On the no-CUDA path (and the `ready` constructor on CUDA hosts)
545        // we resolve immediately. The permit is held until the future is
546        // dropped, which provides the back-pressure semantics needed by
547        // the host bridge even without real CUDA.
548        #[cfg(feature = "cuda")]
549        {
550            if let Some(ev) = self.event.as_ref() {
551                // cust 0.3.2's Event::query returns CudaResult<EventStatus>;
552                // the enum is { Ready, NotReady }. EventStatus::Ready means the
553                // GPU work has completed (equivalent to Event::synchronize for
554                // Unified Memory per the cust docs).
555                //
556                // On NotReady / Err we DO NOT call `waker.wake_by_ref()`
557                // immediately — that creates a busy-spin loop that pins
558                // the Tokio worker at 100% CPU until the event finishes
559                // (every poll re-wakes synchronously). Instead, we clone
560                // the waker, spawn a 50 µs sleep, and have THAT task wake
561                // us up. The worker is free to schedule other tasks in
562                // the meantime. 50 µs is a reasonable poll interval for
563                // GPU events: most kernels take longer than that, so we
564                // miss at most one quantum of post-completion latency,
565                // and the worker spends ≈ 50 µs / quantum doing other
566                // work instead of spinning. v0.4 cuda-async work (see
567                // RFC 0001 + F3 bench scaffold) replaces this with a
568                // proper `cuStreamAddCallback`-driven waker.
569                return match ev.query() {
570                    Ok(cust::event::EventStatus::Ready) => std::task::Poll::Ready(()),
571                    Ok(cust::event::EventStatus::NotReady) | Err(_) => {
572                        let waker = cx.waker().clone();
573                        tokio::spawn(async move {
574                            tokio::time::sleep(std::time::Duration::from_micros(50)).await;
575                            waker.wake();
576                        });
577                        std::task::Poll::Pending
578                    }
579                };
580            }
581        }
582        std::task::Poll::Ready(())
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[tokio::test]
591    async fn acquire_and_release() {
592        let bp = BackPressure::with_cap(2);
593        assert_eq!(bp.active(), 0);
594        let a = bp.acquire().await;
595        assert_eq!(bp.active(), 1);
596        let b = bp.acquire().await;
597        assert_eq!(bp.active(), 2);
598        drop(a);
599        drop(b);
600        assert_eq!(bp.active(), 0);
601    }
602
603    #[tokio::test]
604    async fn try_acquire_under_pressure() {
605        let bp = BackPressure::with_cap(1);
606        let a = bp.acquire().await;
607        assert!(
608            bp.try_acquire().is_none(),
609            "second permit should be unavailable"
610        );
611        drop(a);
612        assert!(
613            bp.try_acquire().is_some(),
614            "permit should be available again"
615        );
616    }
617
618    #[tokio::test]
619    async fn dispatch_future_resolves_immediately() {
620        let bp = BackPressure::with_cap(4);
621        let permit = bp.acquire().await;
622        let fut = DispatchFuture::ready(permit);
623        fut.await;
624        // Permit released — counter should be back to zero.
625        assert_eq!(bp.active(), 0);
626    }
627
628    #[tokio::test]
629    async fn concurrent_acquire_progresses() {
630        // 1000 awaits with a cap of 64 should all complete.
631        let bp = BackPressure::with_cap(64);
632        let mut handles = Vec::new();
633        for _ in 0..1000 {
634            let bp = bp.clone();
635            handles.push(tokio::spawn(async move {
636                let permit = bp.acquire().await;
637                DispatchFuture::ready(permit).await;
638            }));
639        }
640        for h in handles {
641            h.await.unwrap();
642        }
643        assert_eq!(bp.active(), 0);
644    }
645
646    #[tokio::test]
647    async fn acquire_borrowed_and_release() {
648        // Mirrors `acquire_and_release` but for the borrowed variant: the
649        // live-counter must rise on acquire and fall on drop just as it
650        // does for the owned `DispatchPermit`.
651        let bp = BackPressure::with_cap(2);
652        assert_eq!(bp.active(), 0);
653        let a = bp.acquire_borrowed().await.expect("permit");
654        assert_eq!(bp.active(), 1);
655        let b = bp.acquire_borrowed().await.expect("permit");
656        assert_eq!(bp.active(), 2);
657        drop(a);
658        drop(b);
659        assert_eq!(bp.active(), 0);
660    }
661
662    #[tokio::test]
663    async fn acquire_borrowed_cap_zero_returns_quota_exceeded() {
664        // Regression: a cap-0 BackPressure used to hang on
665        // `Semaphore::acquire` because no permit will ever be released.
666        // The fix is to detect the saturated-cap-0 case and return
667        // `QuotaExceeded` synchronously instead of parking forever.
668        let bp = BackPressure::with_cap(0);
669        let err = bp.acquire_borrowed().await.expect_err("cap=0 must error");
670        assert_eq!(err, AbiError::QuotaExceeded);
671        // Repeated calls keep returning the same error rather than
672        // parking — the contract is "saturated forever".
673        let err2 = bp.acquire_borrowed().await.expect_err("cap=0 must error");
674        assert_eq!(err2, AbiError::QuotaExceeded);
675        // No permit was ever issued; the live-counter stayed at zero.
676        assert_eq!(bp.active(), 0);
677    }
678
679    #[test]
680    fn defaults_are_consistent() {
681        let bp = BackPressure::new();
682        assert_eq!(bp.max_concurrent(), DEFAULT_MAX_CONCURRENT_GPU_OPS);
683        assert_eq!(bp.active(), 0);
684    }
685
686    // ----------------------------------------------------------------
687    // Deadline-aware tests (T36).
688    // ----------------------------------------------------------------
689
690    #[test]
691    fn deadline_hint_round_trips() {
692        // The builder consumes self and returns a clone with the
693        // deadline installed; the underlying semaphore Arc is shared,
694        // so two clones can carry distinct deadlines while still
695        // contending on the same pool.
696        let bp = BackPressure::with_cap(4);
697        assert!(bp.deadline_hint().is_none());
698        let d = Instant::now() + Duration::from_secs(1);
699        let bp = bp.with_deadline_hint(Some(d));
700        assert_eq!(bp.deadline_hint(), Some(d));
701    }
702
703    #[tokio::test]
704    async fn acquire_borrowed_rejects_new_under_deadline_near() {
705        // Deadline 30 ms out, inside the 50 ms NEAR window from
706        // construction. The first acquire must be rejected with the
707        // DeadlineNear-mapped QuotaExceeded code.
708        let bp = BackPressure::with_cap(4)
709            .with_deadline_hint(Some(Instant::now() + Duration::from_millis(30)));
710        let err = bp
711            .acquire_borrowed()
712            .await
713            .expect_err("near-deadline acquire must be refused");
714        assert_eq!(err, AbiError::QuotaExceeded);
715        assert_eq!(bp.active(), 0);
716    }
717
718    #[tokio::test]
719    async fn acquire_borrowed_rejects_past_deadline() {
720        // Deadline in the past — every acquire is refused.
721        let bp = BackPressure::with_cap(4)
722            .with_deadline_hint(Some(Instant::now() - Duration::from_millis(5)));
723        let err = bp
724            .acquire_borrowed()
725            .await
726            .expect_err("elapsed deadline must refuse");
727        assert_eq!(err, AbiError::QuotaExceeded);
728    }
729
730    #[tokio::test]
731    async fn acquire_borrowed_passes_outside_near_window() {
732        // Deadline well outside the 50 ms NEAR window — the acquire
733        // proceeds as before.
734        let bp = BackPressure::with_cap(4)
735            .with_deadline_hint(Some(Instant::now() + Duration::from_secs(10)));
736        let permit = bp.acquire_borrowed().await.expect("permit");
737        assert_eq!(bp.active(), 1);
738        drop(permit);
739        assert_eq!(bp.active(), 0);
740    }
741
742    #[tokio::test]
743    async fn in_flight_completes_under_near_deadline() {
744        // Acquire BEFORE the deadline enters the NEAR window; then
745        // advance into the window. The in-flight permit is
746        // unaffected (its Drop releases as normal), but a NEW
747        // acquire is refused.
748        let bp = BackPressure::with_cap(4)
749            .with_deadline_hint(Some(Instant::now() + Duration::from_millis(80)));
750        let in_flight = bp.acquire_borrowed().await.expect("first permit");
751        assert_eq!(bp.active(), 1);
752        // Sleep into the NEAR window (80 ms - 50 ms = 30 ms, so
753        // 40 ms gets us safely inside).
754        tokio::time::sleep(Duration::from_millis(40)).await;
755        let err = bp
756            .acquire_borrowed()
757            .await
758            .expect_err("second acquire must be refused");
759        assert_eq!(err, AbiError::QuotaExceeded);
760        // The in-flight permit is still live.
761        assert_eq!(bp.active(), 1);
762        drop(in_flight);
763        assert_eq!(bp.active(), 0);
764    }
765
766    #[tokio::test]
767    async fn typed_acquire_with_deadline_surfaces_variants() {
768        // `acquire_with_deadline` keeps the typed `BackPressureError`
769        // surface (rather than collapsing to AbiError) so callers
770        // that want to distinguish the variants can.
771        let bp = BackPressure::with_cap(2)
772            .with_deadline_hint(Some(Instant::now() + Duration::from_millis(20)));
773        let err = bp
774            .acquire_with_deadline()
775            .await
776            .expect_err("near-deadline must refuse");
777        assert_eq!(err, BackPressureError::DeadlineNear);
778
779        let bp = BackPressure::with_cap(2)
780            .with_deadline_hint(Some(Instant::now() - Duration::from_millis(5)));
781        let err = bp
782            .acquire_with_deadline()
783            .await
784            .expect_err("elapsed-deadline must refuse");
785        assert_eq!(err, BackPressureError::DeadlineElapsed);
786    }
787
788    #[test]
789    fn backpressure_error_converts_to_abi_error() {
790        // All variants collapse to QuotaExceeded for back-compat
791        // with the existing host-function return path.
792        assert_eq!(
793            AbiError::from(BackPressureError::Saturated),
794            AbiError::QuotaExceeded
795        );
796        assert_eq!(
797            AbiError::from(BackPressureError::DeadlineNear),
798            AbiError::QuotaExceeded
799        );
800        assert_eq!(
801            AbiError::from(BackPressureError::DeadlineElapsed),
802            AbiError::QuotaExceeded
803        );
804    }
805}