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}