Skip to main content

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}