grafton_ndi/async_runtime.rs
1//! Async runtime integration for Tokio and smol.
2//!
3//! This module provides async wrappers around the synchronous NDI receiver API,
4//! allowing integration with async Rust applications using Tokio or smol runtimes.
5//!
6//! The NDI SDK operations are inherently synchronous and blocking, so these wrappers
7//! use `spawn_blocking` internally to run NDI operations on a thread pool without
8//! blocking the async runtime.
9//!
10//! For `capture` and `try_capture`, the timeout budget starts when the future
11//! is first polled, rather than when it is constructed. The same deadline covers
12//! blocking-pool queue delay, receiver capture-gate contention, and SDK waits.
13//! This is a wait budget, not a hard completion deadline: scheduling, frame copying,
14//! and other processing can delay the result beyond it.
15//!
16//! Dropping a future sets a cancellation flag. Work is skipped if the blocking
17//! closure observes that flag at its entry gate. After passing that gate, work
18//! continues, including waiting for receiver access; it cannot be interrupted by
19//! dropping the future.
20//!
21//! # Features
22//!
23//! - `tokio` - Enable Tokio runtime support
24//! - `smol` - Enable smol runtime support
25//!
26//! # Example with Tokio
27//!
28//! ```no_run
29//! # #[cfg(feature = "tokio")]
30//! # {
31//! use grafton_ndi::{NDI, ReceiverOptionsBuilder, tokio::AsyncReceiver};
32//!
33//! #[tokio::main]
34//! async fn main() -> Result<(), grafton_ndi::Error> {
35//! let ndi = NDI::new()?;
36//! // ... obtain source from finder ...
37//! # let source = grafton_ndi::Source {
38//! # name: "Test".into(),
39//! # address: grafton_ndi::SourceAddress::None
40//! # };
41//!
42//! let options = ReceiverOptionsBuilder::snapshot_preset(source).build();
43//! let receiver = grafton_ndi::Receiver::new(&ndi, &options)?;
44//!
45//! let async_receiver = AsyncReceiver::new(receiver);
46//!
47//! // Capture frame asynchronously without blocking the runtime
48//! let frame = async_receiver.video().capture(std::time::Duration::from_secs(5)).await?;
49//! println!("Captured {}x{} frame", frame.width(), frame.height());
50//!
51//! Ok(())
52//! }
53//! # }
54//! ```
55
56use std::{
57 future::Future,
58 marker::PhantomData,
59 sync::{
60 atomic::{AtomicBool, Ordering},
61 Arc,
62 },
63 time::{Duration, Instant},
64};
65
66use crate::{
67 capture::{AudioKind, CaptureKind, MetadataKind, VideoKind},
68 to_ms_checked, ConnectionStats, Error, Receiver, Result,
69};
70
71// Dropping a future prevents its queued closure from starting SDK work. Work
72// that has already passed this gate cannot be interrupted safely.
73struct CancelOnDrop(Arc<AtomicBool>);
74
75impl Drop for CancelOnDrop {
76 fn drop(&mut self) {
77 self.0.store(true, Ordering::Release);
78 }
79}
80
81fn blocking_operation<F, T>(operation: F) -> (CancelOnDrop, impl FnOnce() -> Result<T> + Send)
82where
83 F: FnOnce() -> T + Send,
84{
85 let cancelled = Arc::new(AtomicBool::new(false));
86 let guard = CancelOnDrop(Arc::clone(&cancelled));
87 let work = move || {
88 if cancelled.load(Ordering::Acquire) {
89 return Err(Error::SpawnFailed(
90 "Blocking operation cancelled before starting".into(),
91 ));
92 }
93 std::panic::catch_unwind(std::panic::AssertUnwindSafe(operation)).map_err(|payload| {
94 let message = payload
95 .downcast_ref::<String>()
96 .map(String::as_str)
97 .or_else(|| payload.downcast_ref::<&str>().copied())
98 .unwrap_or("non-string panic payload");
99 Error::SpawnFailed(format!("Blocking operation panicked: {message}"))
100 })
101 };
102 (guard, work)
103}
104
105/// Trait for async runtime spawn-blocking abstraction.
106///
107/// This trait enables runtime-agnostic async code by abstracting the spawn-blocking
108/// mechanism. Each runtime (Tokio, smol) provides its own implementation.
109///
110/// The trait is sealed to prevent external implementations and ensure consistent
111/// error handling across all supported runtimes.
112pub trait SpawnBlocking: sealed::Sealed + Clone + Send + Sync + 'static {
113 /// Spawns a blocking operation on a thread pool and returns its result.
114 ///
115 /// # Errors
116 ///
117 /// Returns `Err(Error::SpawnFailed)` if the blocking task panics or is cancelled.
118 fn spawn_blocking<F, R>(f: F) -> impl Future<Output = Result<R>> + Send
119 where
120 F: FnOnce() -> R + Send + 'static,
121 R: Send + 'static;
122}
123
124mod sealed {
125 pub trait Sealed {}
126
127 #[cfg(feature = "tokio")]
128 impl Sealed for super::TokioRuntime {}
129
130 #[cfg(feature = "smol")]
131 impl Sealed for super::SmolRuntime {}
132}
133
134/// Tokio async runtime marker type.
135///
136/// Used as a type parameter for [`AsyncReceiverGeneric`] to select Tokio's
137/// `spawn_blocking` implementation.
138#[cfg(feature = "tokio")]
139#[derive(Clone, Copy, Debug, Default)]
140pub struct TokioRuntime;
141
142#[cfg(feature = "tokio")]
143impl SpawnBlocking for TokioRuntime {
144 // Using `impl Future` instead of `async fn` in trait because we need explicit
145 // Send bounds on the returned future. This pattern is intentional.
146 #[allow(clippy::manual_async_fn)]
147 fn spawn_blocking<F, R>(f: F) -> impl Future<Output = Result<R>> + Send
148 where
149 F: FnOnce() -> R + Send + 'static,
150 R: Send + 'static,
151 {
152 async {
153 let handle = ::tokio::runtime::Handle::try_current()
154 .map_err(|e| Error::SpawnFailed(e.to_string()))?;
155 let (_cancellation, work) = blocking_operation(f);
156 handle
157 .spawn_blocking(work)
158 .await
159 .map_err(|e| Error::SpawnFailed(e.to_string()))?
160 }
161 }
162}
163
164/// smol runtime marker type.
165///
166/// Used as a type parameter for [`AsyncReceiverGeneric`] to select smol's
167/// `unblock` implementation.
168#[cfg(feature = "smol")]
169#[derive(Clone, Copy, Debug, Default)]
170pub struct SmolRuntime;
171
172#[cfg(feature = "smol")]
173impl SpawnBlocking for SmolRuntime {
174 // Using `impl Future` instead of `async fn` in trait because we need explicit
175 // Send bounds on the returned future. This pattern is intentional.
176 #[allow(clippy::manual_async_fn)]
177 fn spawn_blocking<F, R>(f: F) -> impl Future<Output = Result<R>> + Send
178 where
179 F: FnOnce() -> R + Send + 'static,
180 R: Send + 'static,
181 {
182 async {
183 let (_cancellation, work) = blocking_operation(f);
184 ::smol::unblock(work).await
185 }
186 }
187}
188
189/// Generic async receiver wrapper parameterized by runtime.
190///
191/// This struct provides async versions of the [`Receiver`] methods by running
192/// blocking NDI operations on the runtime's thread pool using `spawn_blocking`.
193///
194/// # Type Parameters
195///
196/// - `R`: The async runtime type, implementing [`SpawnBlocking`]. Use
197/// [`TokioRuntime`] or [`SmolRuntime`].
198///
199/// # Thread Safety
200///
201/// The underlying [`Receiver`] is wrapped in an [`Arc`] to allow sharing across
202/// async tasks and safe cloning. The NDI SDK receiver is inherently thread-safe.
203///
204/// # Example
205///
206/// ```no_run
207/// # #[cfg(feature = "tokio")]
208/// # {
209/// use grafton_ndi::{NDI, ReceiverOptionsBuilder, tokio::AsyncReceiver};
210///
211/// #[tokio::main]
212/// async fn main() -> Result<(), grafton_ndi::Error> {
213/// let ndi = NDI::new()?;
214/// // ... obtain source ...
215/// # let source = grafton_ndi::Source {
216/// # name: "Test".into(),
217/// # address: grafton_ndi::SourceAddress::None
218/// # };
219///
220/// let options = ReceiverOptionsBuilder::snapshot_preset(source).build();
221/// let receiver = grafton_ndi::Receiver::new(&ndi, &options)?;
222/// let async_receiver = AsyncReceiver::new(receiver);
223///
224/// // Non-blocking async capture
225/// match async_receiver.video().try_capture(std::time::Duration::from_millis(100)).await? {
226/// Some(frame) => println!("Got frame: {}x{}", frame.width(), frame.height()),
227/// None => println!("No frame available"),
228/// }
229///
230/// Ok(())
231/// }
232/// # }
233/// ```
234pub struct AsyncReceiverGeneric<R: SpawnBlocking> {
235 inner: Arc<Receiver>,
236 _runtime: PhantomData<R>,
237}
238
239fn validated_deadline(timeout: Duration) -> Result<Instant> {
240 to_ms_checked(timeout)?;
241 Instant::now()
242 .checked_add(timeout)
243 .ok_or_else(|| Error::Timeout("Capture deadline cannot be represented".into()))
244}
245
246// Shared by both capture verbs so validation and queue accounting agree.
247async fn with_timeout_budget<R, F, T>(timeout: Duration, operation: F) -> Result<T>
248where
249 R: SpawnBlocking,
250 F: FnOnce(Instant) -> Result<T> + Send + 'static,
251 T: Send + 'static,
252{
253 let deadline = validated_deadline(timeout)?;
254 R::spawn_blocking(move || operation(deadline)).await?
255}
256
257impl<R: SpawnBlocking> AsyncReceiverGeneric<R> {
258 /// Create a new async receiver wrapper.
259 ///
260 /// The receiver is wrapped in an [`Arc`] to allow sharing across async tasks.
261 pub fn new(receiver: Receiver) -> Self {
262 Self {
263 inner: Arc::new(receiver),
264 _runtime: PhantomData,
265 }
266 }
267
268 /// Capture **video** frames without blocking the async runtime.
269 ///
270 /// Returns an [`AsyncCapture`] view; see [`AsyncCapture`] for its verbs
271 /// ([`capture`](AsyncCapture::capture),
272 /// [`try_capture`](AsyncCapture::try_capture)). Mirrors
273 /// [`Receiver::video`], running each capture on the blocking pool.
274 ///
275 /// # Example
276 ///
277 /// ```no_run
278 /// # #[cfg(feature = "tokio")]
279 /// # {
280 /// # use grafton_ndi::{NDI, ReceiverOptions, tokio::AsyncReceiver};
281 /// # use std::time::Duration;
282 /// # #[tokio::main]
283 /// # async fn main() -> Result<(), grafton_ndi::Error> {
284 /// # let ndi = NDI::new()?;
285 /// # let source = grafton_ndi::Source {
286 /// # name: "Test".into(),
287 /// # address: grafton_ndi::SourceAddress::None
288 /// # };
289 /// # let options = ReceiverOptions::builder(source).build();
290 /// # let receiver = grafton_ndi::Receiver::new(&ndi, &options)?;
291 /// let async_receiver = AsyncReceiver::new(receiver);
292 /// let frame = async_receiver.video().capture(Duration::from_secs(5)).await?;
293 /// println!("Captured {}x{} frame", frame.width(), frame.height());
294 /// # Ok(())
295 /// # }
296 /// # }
297 /// ```
298 #[must_use = "the AsyncCapture view does nothing until a capture verb is awaited"]
299 pub fn video(&self) -> AsyncCapture<'_, R, VideoKind> {
300 AsyncCapture::new(self)
301 }
302
303 /// Capture **audio** frames without blocking the async runtime.
304 ///
305 /// Returns an [`AsyncCapture`] view; see [`video`](Self::video) for usage.
306 /// Mirrors [`Receiver::audio`].
307 #[must_use = "the AsyncCapture view does nothing until a capture verb is awaited"]
308 pub fn audio(&self) -> AsyncCapture<'_, R, AudioKind> {
309 AsyncCapture::new(self)
310 }
311
312 /// Capture **metadata** frames without blocking the async runtime.
313 ///
314 /// Returns an [`AsyncCapture`] view; see [`video`](Self::video) for usage.
315 /// Mirrors [`Receiver::metadata`].
316 #[must_use = "the AsyncCapture view does nothing until a capture verb is awaited"]
317 pub fn metadata(&self) -> AsyncCapture<'_, R, MetadataKind> {
318 AsyncCapture::new(self)
319 }
320
321 /// Whether the underlying receiver currently has at least one active
322 /// connection to its source. See [`Receiver::is_connected`].
323 ///
324 /// A cheap, non-blocking SDK query, so it runs inline rather than on
325 /// the blocking pool.
326 pub fn is_connected(&self) -> bool {
327 self.inner.is_connected()
328 }
329
330 /// Connection and frame-throughput statistics for the underlying
331 /// receiver. See [`Receiver::connection_stats`].
332 ///
333 /// A cheap, non-blocking SDK query, so it runs inline rather than on
334 /// the blocking pool. `connection_stats().video_frames_received` is
335 /// the canonical liveness signal: it advances as the receiver pulls
336 /// frames off the network, independent of how often the caller
337 /// captures, so a frozen counter means the feed itself has stalled.
338 pub fn connection_stats(&self) -> ConnectionStats {
339 self.inner.connection_stats()
340 }
341
342 /// Re-establish the underlying receiver's connection to its source
343 /// in place. See [`Receiver::reconnect`].
344 ///
345 /// Unlike the liveness probes above, a reconnect takes the receiver's
346 /// capture guard exclusively, so it can block until in-flight captures
347 /// (which run on the blocking pool) drain. It therefore runs on the
348 /// blocking pool too, never on the async runtime's threads, and is safe to
349 /// call while captures on this receiver are in flight — they serialize
350 /// instead of racing. Confirm recovery via [`Self::connection_stats`].
351 ///
352 /// # Cancellation
353 ///
354 /// Dropping this future skips work if the blocking closure observes the
355 /// cancellation flag at its entry gate. After passing that gate, reconnect
356 /// continues, including waiting for in-flight captures, and may reconnect
357 /// after this future is dropped.
358 pub async fn reconnect(&self) -> Result<()> {
359 let receiver = Arc::clone(&self.inner);
360 R::spawn_blocking(move || receiver.reconnect()).await?
361 }
362}
363
364/// A typed view over an async receiver for capturing frames of one kind,
365/// without blocking the async runtime.
366///
367/// Created by [`AsyncReceiverGeneric::video`], [`audio`](AsyncReceiverGeneric::audio),
368/// and [`metadata`](AsyncReceiverGeneric::metadata). Each verb runs the
369/// underlying synchronous capture on the runtime's blocking pool:
370///
371/// - [`capture`](Self::capture) — reliable owned capture with built-in retry.
372/// The timeout budget starts when the future is first polled; `spawn_blocking`
373/// queue delay is subtracted before the SDK begins waiting.
374/// - [`try_capture`](Self::try_capture) — a single owned poll; `Ok(None)` when
375/// no frame is ready. Its budget also includes blocking task queue delay.
376///
377/// There is no zero-copy `try_capture_ref` here: a borrowed frame is tied to
378/// the receiver and cannot cross the `spawn_blocking` boundary. Use
379/// [`Receiver::video`]'s [`Capture::try_capture_ref`](crate::Capture::try_capture_ref)
380/// on the synchronous receiver for in-place processing.
381pub struct AsyncCapture<'rx, R: SpawnBlocking, K: CaptureKind> {
382 recv: &'rx AsyncReceiverGeneric<R>,
383 _kind: PhantomData<K>,
384}
385
386impl<'rx, R: SpawnBlocking, K: CaptureKind> AsyncCapture<'rx, R, K>
387where
388 K::Owned: Send + 'static,
389{
390 fn new(recv: &'rx AsyncReceiverGeneric<R>) -> Self {
391 Self {
392 recv,
393 _kind: PhantomData,
394 }
395 }
396
397 /// Async version of [`Capture::capture`](crate::Capture::capture):
398 /// reliable owned capture that retries across the SDK's initial-sync
399 /// warm-up, run on the blocking pool.
400 ///
401 /// # Arguments
402 ///
403 /// * `timeout` - Total budget to wait for a frame, starting when the future
404 /// is first polled. Blocking-pool queue delay and capture-gate contention
405 /// count against the same budget. Completion can occur after this budget
406 /// because scheduling and frame processing are not bounded by it.
407 /// Must not exceed
408 /// [`crate::MAX_TIMEOUT`] (~49.7 days).
409 ///
410 /// # Returns
411 ///
412 /// * `Ok(frame)` - Successfully captured a frame
413 /// * `Err(Error::FrameTimeout)` - No frame received within timeout (includes retry details)
414 /// * `Err(Error::SpawnFailed)` - The blocking task panicked or was cancelled
415 /// * `Err(_)` - Another error occurred during capture
416 ///
417 /// # Cancellation
418 ///
419 /// Dropping this future skips work if the blocking closure observes the
420 /// cancellation flag at its entry gate. After passing that gate, work
421 /// continues, including waiting for receiver access, and can consume a frame
422 /// whose result is discarded.
423 pub async fn capture(&self, timeout: Duration) -> Result<K::Owned> {
424 let receiver = Arc::clone(&self.recv.inner);
425 with_timeout_budget::<R, _, _>(timeout, move |deadline| {
426 receiver.core.capture_kind_until::<K>(deadline)
427 })
428 .await
429 }
430
431 /// Async version of
432 /// [`Capture::try_capture`](crate::Capture::try_capture): a single owned
433 /// poll, run on the blocking pool.
434 ///
435 /// # Arguments
436 ///
437 /// * `timeout` - Total wait budget from the future's first poll, including
438 /// blocking-pool queue delay and capture-gate contention. With an exhausted
439 /// budget, an immediately available gate permits a zero-timeout SDK poll;
440 /// otherwise this returns `Ok(None)` without an SDK call. Scheduling and
441 /// frame processing can delay completion beyond this budget.
442 /// Must not exceed
443 /// [`crate::MAX_TIMEOUT`] (~49.7 days).
444 ///
445 /// # Returns
446 ///
447 /// * `Ok(Some(frame))` - Successfully captured a frame
448 /// * `Ok(None)` - No frame available within timeout
449 /// * `Err(Error::SpawnFailed)` - The blocking task panicked or was cancelled
450 /// * `Err(_)` - An error occurred during capture
451 ///
452 /// # Cancellation
453 ///
454 /// Dropping this future skips work if the blocking closure observes the
455 /// cancellation flag at its entry gate. After passing that gate, work
456 /// continues, including waiting for receiver access, and can consume a frame
457 /// whose result is discarded.
458 pub async fn try_capture(&self, timeout: Duration) -> Result<Option<K::Owned>> {
459 let receiver = Arc::clone(&self.recv.inner);
460 with_timeout_budget::<R, _, _>(timeout, move |deadline| {
461 receiver.core.try_capture_kind_until::<K>(deadline)
462 })
463 .await
464 }
465}
466
467impl<R: SpawnBlocking> Clone for AsyncReceiverGeneric<R> {
468 fn clone(&self) -> Self {
469 Self {
470 inner: Arc::clone(&self.inner),
471 _runtime: PhantomData,
472 }
473 }
474}
475
476// Runtime-specific receiver aliases
477
478#[cfg(feature = "tokio")]
479pub mod tokio {
480 //! Tokio async runtime integration.
481 //!
482 //! Provides [`AsyncReceiver`] wrapper that uses `tokio::task::spawn_blocking`
483 //! to run NDI operations without blocking the Tokio runtime.
484 //!
485 //! # Example
486 //!
487 //! ```no_run
488 //! # #[cfg(feature = "tokio")]
489 //! # {
490 //! use grafton_ndi::{NDI, ReceiverOptionsBuilder, tokio::AsyncReceiver};
491 //!
492 //! #[tokio::main]
493 //! async fn main() -> Result<(), grafton_ndi::Error> {
494 //! let ndi = NDI::new()?;
495 //! // ... obtain source ...
496 //! # let source = grafton_ndi::Source {
497 //! # name: "Test".into(),
498 //! # address: grafton_ndi::SourceAddress::None
499 //! # };
500 //!
501 //! let options = ReceiverOptionsBuilder::snapshot_preset(source).build();
502 //! let receiver = grafton_ndi::Receiver::new(&ndi, &options)?;
503 //! let async_receiver = AsyncReceiver::new(receiver);
504 //!
505 //! // Non-blocking async capture
506 //! match async_receiver.video().try_capture(std::time::Duration::from_millis(100)).await? {
507 //! Some(frame) => println!("Got frame: {}x{}", frame.width(), frame.height()),
508 //! None => println!("No frame available"),
509 //! }
510 //!
511 //! Ok(())
512 //! }
513 //! # }
514 //! ```
515
516 use super::{AsyncReceiverGeneric, TokioRuntime};
517
518 /// Async receiver wrapper for Tokio runtime.
519 ///
520 /// This is a type alias for the generic async receiver parameterized with
521 /// the Tokio runtime. It provides async versions of the [`crate::Receiver`]
522 /// methods by running blocking NDI operations on Tokio's blocking thread
523 /// pool using `spawn_blocking`.
524 ///
525 /// # Thread Safety
526 ///
527 /// The underlying `Receiver` is wrapped in an `Arc` to allow sharing across
528 /// async tasks and safe cloning. The NDI SDK receiver is inherently thread-safe.
529 ///
530 /// # Error Handling
531 ///
532 /// All methods return [`crate::Result`], converting any task panic or cancellation
533 /// into [`crate::Error::SpawnFailed`] rather than propagating the panic.
534 pub type AsyncReceiver = AsyncReceiverGeneric<TokioRuntime>;
535}
536
537#[cfg(feature = "smol")]
538pub mod smol {
539 //! smol runtime integration.
540 //!
541 //! Provides [`AsyncReceiver`] wrapper that uses `::smol::unblock`
542 //! to run NDI operations without blocking the smol runtime.
543 //!
544 //! # Example
545 //!
546 //! ```no_run
547 //! # #[cfg(feature = "smol")]
548 //! # {
549 //! use grafton_ndi::{NDI, ReceiverOptionsBuilder, smol::AsyncReceiver};
550 //!
551 //! fn main() -> Result<(), grafton_ndi::Error> {
552 //! ::smol::block_on(async {
553 //! let ndi = NDI::new()?;
554 //! // ... obtain source ...
555 //! # let source = grafton_ndi::Source {
556 //! # name: "Test".into(),
557 //! # address: grafton_ndi::SourceAddress::None
558 //! # };
559 //!
560 //! let options = ReceiverOptionsBuilder::snapshot_preset(source).build();
561 //! let receiver = grafton_ndi::Receiver::new(&ndi, &options)?;
562 //! let async_receiver = AsyncReceiver::new(receiver);
563 //!
564 //! // Non-blocking async capture
565 //! match async_receiver.video().try_capture(std::time::Duration::from_millis(100)).await? {
566 //! Some(frame) => println!("Got frame: {}x{}", frame.width(), frame.height()),
567 //! None => println!("No frame available"),
568 //! }
569 //!
570 //! Ok(())
571 //! })
572 //! }
573 //! # }
574 //! ```
575
576 use super::{AsyncReceiverGeneric, SmolRuntime};
577
578 /// Async receiver wrapper for smol runtime.
579 ///
580 /// This is a type alias for the generic async receiver parameterized with
581 /// the smol runtime. It provides async versions of the [`crate::Receiver`]
582 /// methods by running blocking NDI operations on smol's blocking thread
583 /// pool using `::smol::unblock`.
584 ///
585 /// # Thread Safety
586 ///
587 /// The underlying `Receiver` is wrapped in an `Arc` to allow sharing across
588 /// async tasks and safe cloning. The NDI SDK receiver is inherently thread-safe.
589 ///
590 /// # Error Handling
591 ///
592 /// All methods return [`crate::Result`], converting blocking-operation
593 /// panics into [`crate::Error::SpawnFailed`].
594 pub type AsyncReceiver = AsyncReceiverGeneric<SmolRuntime>;
595}
596
597#[cfg(all(test, feature = "tokio"))]
598mod timeout_tests {
599 use super::*;
600 use std::{
601 sync::mpsc,
602 task::{Context, Poll, Waker},
603 };
604
605 fn runtime() -> ::tokio::runtime::Runtime {
606 ::tokio::runtime::Builder::new_current_thread()
607 .max_blocking_threads(1)
608 .build()
609 .unwrap()
610 }
611
612 #[test]
613 fn queued_work_uses_remaining_budget() {
614 let runtime = runtime();
615 runtime.block_on(async {
616 let (started_tx, started_rx) = mpsc::channel();
617 let (release_tx, release_rx) = mpsc::channel();
618 let blocker = ::tokio::task::spawn_blocking(move || {
619 started_tx.send(()).unwrap();
620 release_rx.recv().unwrap();
621 });
622 started_rx.recv().unwrap();
623
624 let budget = Duration::from_millis(20);
625 let mut capture = Box::pin(with_timeout_budget::<TokioRuntime, _, _>(
626 budget,
627 |deadline| Ok(deadline.saturating_duration_since(Instant::now())),
628 ));
629 let mut context = Context::from_waker(Waker::noop());
630 assert!(matches!(capture.as_mut().poll(&mut context), Poll::Pending));
631 // Keep the sole worker occupied until the whole budget has elapsed.
632 std::thread::sleep(budget + Duration::from_millis(5));
633 release_tx.send(()).unwrap();
634 assert_eq!(capture.await.unwrap(), Duration::ZERO);
635 blocker.await.unwrap();
636 });
637 }
638
639 #[test]
640 fn invalid_budget_is_rejected_before_spawning() {
641 // No entered runtime: trying to submit work would panic.
642 let mut capture = Box::pin(with_timeout_budget::<TokioRuntime, _, ()>(
643 crate::MAX_TIMEOUT + Duration::from_secs(1),
644 |_| panic!("invalid timeout must not execute blocking work"),
645 ));
646 let mut context = Context::from_waker(Waker::noop());
647 assert!(matches!(
648 capture.as_mut().poll(&mut context),
649 Poll::Ready(Err(_))
650 ));
651 }
652}
653
654#[cfg(all(test, feature = "smol"))]
655mod smol_tests {
656 use super::*;
657 use std::{
658 sync::mpsc,
659 task::{Context, Poll, Waker},
660 };
661
662 #[test]
663 fn dropping_unpolled_future_starts_no_work() {
664 let called = Arc::new(AtomicBool::new(false));
665 let worker_called = Arc::clone(&called);
666 let operation = SmolRuntime::spawn_blocking(move || {
667 worker_called.store(true, Ordering::Release);
668 });
669 drop(operation);
670 assert!(!called.load(Ordering::Acquire));
671 }
672
673 #[test]
674 fn dropping_started_future_allows_work_to_finish() {
675 let (started_tx, started_rx) = mpsc::channel();
676 let (release_tx, release_rx) = mpsc::channel();
677 let (finished_tx, finished_rx) = mpsc::channel();
678 let mut operation = Box::pin(SmolRuntime::spawn_blocking(move || {
679 started_tx.send(()).unwrap();
680 release_rx.recv().unwrap();
681 finished_tx.send(42).unwrap();
682 }));
683 let mut context = Context::from_waker(Waker::noop());
684 assert!(matches!(
685 operation.as_mut().poll(&mut context),
686 Poll::Pending
687 ));
688 started_rx.recv_timeout(Duration::from_secs(5)).unwrap();
689 drop(operation);
690 release_tx.send(()).unwrap();
691 assert_eq!(
692 finished_rx.recv_timeout(Duration::from_secs(5)).unwrap(),
693 42
694 );
695 }
696
697 #[test]
698 fn invalid_budget_does_not_start_work() {
699 let called = Arc::new(AtomicBool::new(false));
700 let worker_called = Arc::clone(&called);
701 let result = ::smol::block_on(with_timeout_budget::<SmolRuntime, _, _>(
702 crate::MAX_TIMEOUT + Duration::from_secs(1),
703 move |_| {
704 worker_called.store(true, Ordering::Release);
705 Ok(())
706 },
707 ));
708 assert!(result.is_err());
709 assert!(!called.load(Ordering::Acquire));
710 }
711
712 #[test]
713 fn budget_starts_on_first_poll_and_keeps_one_deadline() {
714 let budget = Duration::from_millis(20);
715 let (started_tx, started_rx) = mpsc::channel();
716 let (release_tx, release_rx) = mpsc::channel();
717 let mut operation = Box::pin(with_timeout_budget::<SmolRuntime, _, _>(
718 budget,
719 move |deadline| {
720 started_tx.send(deadline).unwrap();
721 release_rx.recv().unwrap();
722 Ok(deadline.saturating_duration_since(Instant::now()))
723 },
724 ));
725 // Construction must not consume the wait budget.
726 std::thread::sleep(budget);
727 let before_poll = Instant::now();
728 let mut context = Context::from_waker(Waker::noop());
729 assert!(matches!(
730 operation.as_mut().poll(&mut context),
731 Poll::Pending
732 ));
733 let after_poll = Instant::now();
734 let deadline = started_rx.recv_timeout(Duration::from_secs(5)).unwrap();
735 assert!(deadline >= before_poll + budget);
736 assert!(deadline <= after_poll + budget);
737 // A wait inside blocking work must not reset the shared capture deadline.
738 std::thread::sleep(deadline.saturating_duration_since(Instant::now()));
739 release_tx.send(()).unwrap();
740 assert_eq!(::smol::block_on(operation).unwrap(), Duration::ZERO);
741 }
742}
743
744#[cfg(test)]
745mod executor_tests {
746 use super::*;
747
748 #[test]
749 fn cancellation_skips_queued_operation() {
750 let (guard, work) = blocking_operation(|| panic!("cancelled work must not run"));
751 drop(guard);
752 assert!(matches!(work(), Err(Error::SpawnFailed(_))));
753 }
754
755 #[test]
756 fn cancellation_allows_started_operation_to_finish() {
757 let (started_tx, started_rx) = std::sync::mpsc::channel();
758 let (finish_tx, finish_rx) = std::sync::mpsc::channel();
759 let (guard, work) = blocking_operation(move || {
760 started_tx.send(()).unwrap();
761 finish_rx.recv().unwrap();
762 42
763 });
764 let worker = std::thread::spawn(work);
765 started_rx.recv().unwrap();
766 drop(guard);
767 finish_tx.send(()).unwrap();
768 assert_eq!(worker.join().unwrap().unwrap(), 42);
769 }
770
771 #[cfg(feature = "tokio")]
772 #[test]
773 fn missing_tokio_context_returns_error() {
774 use std::task::{Context, Poll, Waker};
775 let mut operation = Box::pin(TokioRuntime::spawn_blocking(|| 42));
776 let mut context = Context::from_waker(Waker::noop());
777 assert!(matches!(
778 operation.as_mut().poll(&mut context),
779 Poll::Ready(Err(Error::SpawnFailed(_)))
780 ));
781 }
782
783 #[cfg(feature = "tokio")]
784 #[test]
785 fn tokio_converts_blocking_panic() {
786 let runtime = ::tokio::runtime::Builder::new_current_thread()
787 .build()
788 .unwrap();
789 let result = runtime.block_on(TokioRuntime::spawn_blocking(|| panic!("test panic")));
790 assert!(
791 matches!(result, Err(Error::SpawnFailed(message)) if message.contains("test panic"))
792 );
793 }
794
795 #[cfg(feature = "smol")]
796 #[test]
797 fn smol_converts_blocking_panic() {
798 let result = ::smol::block_on(SmolRuntime::spawn_blocking(|| panic!("test panic")));
799 assert!(
800 matches!(result, Err(Error::SpawnFailed(message)) if message.contains("test panic"))
801 );
802 }
803
804 #[cfg(feature = "tokio")]
805 #[test]
806 fn dropping_tokio_future_skips_queued_work() {
807 use std::task::{Context, Poll, Waker};
808 let runtime = ::tokio::runtime::Builder::new_current_thread()
809 .max_blocking_threads(1)
810 .build()
811 .unwrap();
812 runtime.block_on(async {
813 let (started_tx, started_rx) = std::sync::mpsc::channel();
814 let (release_tx, release_rx) = std::sync::mpsc::channel();
815 let blocker = ::tokio::task::spawn_blocking(move || {
816 started_tx.send(()).unwrap();
817 release_rx.recv().unwrap();
818 });
819 started_rx.recv().unwrap();
820 let called = Arc::new(AtomicBool::new(false));
821 let worker_called = Arc::clone(&called);
822 let mut operation = Box::pin(TokioRuntime::spawn_blocking(move || {
823 worker_called.store(true, Ordering::Release)
824 }));
825 let mut context = Context::from_waker(Waker::noop());
826 assert!(matches!(
827 operation.as_mut().poll(&mut context),
828 Poll::Pending
829 ));
830 drop(operation);
831 release_tx.send(()).unwrap();
832 blocker.await.unwrap();
833 // The single worker processes the cancelled closure before this sentinel.
834 ::tokio::task::spawn_blocking(|| ()).await.unwrap();
835 assert!(!called.load(Ordering::Acquire));
836 });
837 }
838}