Skip to main content

oxicuda_launch/
async_launch.rs

1//! Async kernel launch with completion futures.
2//!
3//! This module provides [`AsyncKernel`] for launching GPU kernels that
4//! return [`Future`]s, enabling integration with Rust's `async`/`await`
5//! ecosystem without depending on any specific async runtime.
6//!
7//! # Architecture
8//!
9//! Since the OxiCUDA driver crate does not expose CUDA callback
10//! registration, completion is detected by **polling**
11//! [`Event::query()`](oxicuda_driver::Event::query). The
12//! [`PollStrategy`] enum controls how aggressively the future polls:
13//!
14//! - [`Spin`](PollStrategy::Spin) — busy-poll with no yielding.
15//! - [`Yield`](PollStrategy::Yield) — call `std::thread::yield_now()`
16//!   between polls.
17//! - [`BackoffMicros`](PollStrategy::BackoffMicros) — sleep a fixed
18//!   number of microseconds between polls.
19//!
20//! # Example
21//!
22//! ```rust,no_run
23//! # use std::sync::Arc;
24//! # use oxicuda_driver::{Module, Stream, Context, Device};
25//! # use oxicuda_launch::{Kernel, LaunchParams, AsyncKernel, PollStrategy, AsyncLaunchConfig};
26//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
27//! # oxicuda_driver::init()?;
28//! # let dev = Device::get(0)?;
29//! # let ctx = Arc::new(Context::new(&dev)?);
30//! # let ptx = "";
31//! # let module = Arc::new(Module::from_ptx(ptx)?);
32//! # let kernel = Kernel::from_module(module, "my_kernel")?;
33//! let async_kernel = AsyncKernel::new(kernel);
34//! let stream = Stream::new(&ctx)?;
35//! let params = LaunchParams::new(4u32, 256u32);
36//!
37//! // Fire-and-await
38//! let completion = async_kernel.launch_async(&params, &stream, &(42u32,))?;
39//! completion.await?;
40//! # Ok(())
41//! # }
42//! ```
43
44use std::future::Future;
45use std::pin::Pin;
46use std::task::{Context, Poll, Waker};
47use std::time::{Duration, Instant};
48
49use oxicuda_driver::error::{CudaError, CudaResult};
50use oxicuda_driver::event::Event;
51use oxicuda_driver::stream::Stream;
52
53use crate::kernel::{Kernel, KernelArgs};
54use crate::params::LaunchParams;
55
56// ---------------------------------------------------------------------------
57// CompletionStatus
58// ---------------------------------------------------------------------------
59
60/// Status of a GPU kernel completion.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum CompletionStatus {
63    /// The kernel has not yet completed.
64    Pending,
65    /// The kernel has completed successfully.
66    Complete,
67    /// An error occurred while querying completion.
68    Error(String),
69}
70
71impl CompletionStatus {
72    /// Returns `true` if the status is [`Complete`](Self::Complete).
73    #[inline]
74    pub fn is_complete(&self) -> bool {
75        matches!(self, Self::Complete)
76    }
77
78    /// Returns `true` if the status is [`Pending`](Self::Pending).
79    #[inline]
80    pub fn is_pending(&self) -> bool {
81        matches!(self, Self::Pending)
82    }
83
84    /// Returns `true` if the status is [`Error`](Self::Error).
85    #[inline]
86    pub fn is_error(&self) -> bool {
87        matches!(self, Self::Error(_))
88    }
89}
90
91impl std::fmt::Display for CompletionStatus {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            Self::Pending => write!(f, "Pending"),
95            Self::Complete => write!(f, "Complete"),
96            Self::Error(msg) => write!(f, "Error: {msg}"),
97        }
98    }
99}
100
101// ---------------------------------------------------------------------------
102// PollStrategy
103// ---------------------------------------------------------------------------
104
105/// Strategy for polling GPU event completion.
106///
107/// Controls the trade-off between CPU usage and latency when waiting
108/// for a kernel to finish.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum PollStrategy {
111    /// Busy-poll `event.query()` with no pause between polls.
112    ///
113    /// Lowest latency but highest CPU usage.
114    Spin,
115
116    /// Call [`std::thread::yield_now()`] between polls.
117    ///
118    /// Allows other threads to run but still polls frequently.
119    Yield,
120
121    /// Sleep for the given number of microseconds between polls.
122    ///
123    /// Lower CPU usage at the cost of higher latency.
124    BackoffMicros(u64),
125}
126
127impl Default for PollStrategy {
128    /// Defaults to [`Yield`](PollStrategy::Yield) for a balanced
129    /// trade-off between latency and CPU usage.
130    #[inline]
131    fn default() -> Self {
132        Self::Yield
133    }
134}
135
136// ---------------------------------------------------------------------------
137// AsyncLaunchConfig
138// ---------------------------------------------------------------------------
139
140/// Configuration for async kernel launch behaviour.
141#[derive(Debug, Clone)]
142pub struct AsyncLaunchConfig {
143    /// Strategy for polling event completion.
144    pub poll_strategy: PollStrategy,
145    /// Optional maximum time to wait before the future resolves with
146    /// a timeout error.
147    pub timeout: Option<Duration>,
148}
149
150impl Default for AsyncLaunchConfig {
151    /// Default config: [`PollStrategy::Yield`], no timeout.
152    #[inline]
153    fn default() -> Self {
154        Self {
155            poll_strategy: PollStrategy::Yield,
156            timeout: None,
157        }
158    }
159}
160
161impl AsyncLaunchConfig {
162    /// Creates a new config with the given poll strategy and no timeout.
163    #[inline]
164    pub fn new(poll_strategy: PollStrategy) -> Self {
165        Self {
166            poll_strategy,
167            timeout: None,
168        }
169    }
170
171    /// Sets the timeout duration.
172    #[inline]
173    pub fn with_timeout(mut self, timeout: Duration) -> Self {
174        self.timeout = Some(timeout);
175        self
176    }
177}
178
179// ---------------------------------------------------------------------------
180// LaunchTiming
181// ---------------------------------------------------------------------------
182
183/// Timing information for a completed kernel launch.
184#[derive(Debug, Clone, Copy, PartialEq)]
185pub struct LaunchTiming {
186    /// Elapsed GPU time in microseconds.
187    pub elapsed_us: f64,
188}
189
190impl LaunchTiming {
191    /// Returns the elapsed time in milliseconds.
192    #[inline]
193    pub fn elapsed_ms(&self) -> f64 {
194        self.elapsed_us / 1000.0
195    }
196
197    /// Returns the elapsed time in seconds.
198    #[inline]
199    pub fn elapsed_secs(&self) -> f64 {
200        self.elapsed_us / 1_000_000.0
201    }
202}
203
204impl std::fmt::Display for LaunchTiming {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        if self.elapsed_us < 1000.0 {
207            write!(f, "{:.2} us", self.elapsed_us)
208        } else if self.elapsed_us < 1_000_000.0 {
209            write!(f, "{:.3} ms", self.elapsed_ms())
210        } else {
211            write!(f, "{:.4} s", self.elapsed_secs())
212        }
213    }
214}
215
216// ---------------------------------------------------------------------------
217// LaunchCompletion
218// ---------------------------------------------------------------------------
219
220/// A [`Future`] that resolves when a GPU kernel finishes execution.
221///
222/// Created by [`AsyncKernel::launch_async`]. The future polls the
223/// underlying CUDA event to detect completion.
224pub struct LaunchCompletion {
225    /// The event recorded after kernel launch.
226    event: Event,
227    /// Poll strategy.
228    strategy: PollStrategy,
229    /// Optional timeout.
230    timeout: Option<Duration>,
231    /// When the future was first polled (lazily initialised).
232    start_time: Option<Instant>,
233    /// Stored waker for background polling thread.
234    waker: Option<Waker>,
235    /// Whether a background poller thread has been spawned.
236    poller_spawned: bool,
237}
238
239impl LaunchCompletion {
240    /// Creates a new completion future wrapping the given event.
241    fn new(event: Event, config: &AsyncLaunchConfig) -> Self {
242        Self {
243            event,
244            strategy: config.poll_strategy,
245            timeout: config.timeout,
246            start_time: None,
247            waker: None,
248            poller_spawned: false,
249        }
250    }
251
252    /// Queries the current completion status without consuming the future.
253    pub fn status(&self) -> CompletionStatus {
254        match self.event.query() {
255            Ok(true) => CompletionStatus::Complete,
256            Ok(false) => CompletionStatus::Pending,
257            Err(e) => CompletionStatus::Error(e.to_string()),
258        }
259    }
260
261    /// Checks whether the timeout (if any) has been exceeded.
262    fn check_timeout(&self) -> bool {
263        match (self.timeout, self.start_time) {
264            (Some(timeout), Some(start)) => start.elapsed() >= timeout,
265            _ => false,
266        }
267    }
268
269    /// Spawns a background thread that polls the event and wakes the
270    /// waker when the event completes or on each poll interval.
271    fn spawn_poller(strategy: PollStrategy, waker: Waker) {
272        std::thread::spawn(move || {
273            match strategy {
274                PollStrategy::Spin => {
275                    // Wake immediately — the executor will re-poll.
276                }
277                PollStrategy::Yield => {
278                    std::thread::yield_now();
279                }
280                PollStrategy::BackoffMicros(us) => {
281                    std::thread::sleep(Duration::from_micros(us));
282                }
283            }
284            waker.wake();
285        });
286    }
287}
288
289impl Future for LaunchCompletion {
290    type Output = CudaResult<()>;
291
292    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
293        // Initialise start time on first poll.
294        if self.start_time.is_none() {
295            self.start_time = Some(Instant::now());
296        }
297
298        // Check timeout.
299        if self.check_timeout() {
300            return Poll::Ready(Err(CudaError::Timeout));
301        }
302
303        // Query the event.
304        match self.event.query() {
305            Ok(true) => Poll::Ready(Ok(())),
306            Ok(false) => {
307                // Store the waker and schedule a re-poll.
308                let waker = cx.waker().clone();
309                self.waker = Some(waker.clone());
310
311                if !self.poller_spawned || self.strategy == PollStrategy::Spin {
312                    self.poller_spawned = true;
313                    Self::spawn_poller(self.strategy, waker);
314                }
315
316                Poll::Pending
317            }
318            Err(e) => Poll::Ready(Err(e)),
319        }
320    }
321}
322
323// ---------------------------------------------------------------------------
324// TimedLaunchCompletion
325// ---------------------------------------------------------------------------
326
327/// A [`Future`] that resolves to [`LaunchTiming`] when a GPU kernel
328/// finishes, measuring elapsed GPU time via CUDA events.
329pub struct TimedLaunchCompletion {
330    /// Event recorded before the kernel launch.
331    start_event: Event,
332    /// Event recorded after the kernel launch.
333    end_event: Event,
334    /// Poll strategy.
335    strategy: PollStrategy,
336    /// Optional timeout.
337    timeout: Option<Duration>,
338    /// When the future was first polled.
339    start_time: Option<Instant>,
340    /// Whether a background poller thread has been spawned.
341    poller_spawned: bool,
342}
343
344impl TimedLaunchCompletion {
345    /// Creates a new timed completion future.
346    fn new(start_event: Event, end_event: Event, config: &AsyncLaunchConfig) -> Self {
347        Self {
348            start_event,
349            end_event,
350            strategy: config.poll_strategy,
351            timeout: config.timeout,
352            start_time: None,
353            poller_spawned: false,
354        }
355    }
356
357    /// Queries the current completion status.
358    pub fn status(&self) -> CompletionStatus {
359        match self.end_event.query() {
360            Ok(true) => CompletionStatus::Complete,
361            Ok(false) => CompletionStatus::Pending,
362            Err(e) => CompletionStatus::Error(e.to_string()),
363        }
364    }
365
366    /// Checks whether the timeout has been exceeded.
367    fn check_timeout(&self) -> bool {
368        match (self.timeout, self.start_time) {
369            (Some(timeout), Some(start)) => start.elapsed() >= timeout,
370            _ => false,
371        }
372    }
373}
374
375impl Future for TimedLaunchCompletion {
376    type Output = CudaResult<LaunchTiming>;
377
378    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
379        if self.start_time.is_none() {
380            self.start_time = Some(Instant::now());
381        }
382
383        if self.check_timeout() {
384            return Poll::Ready(Err(CudaError::Timeout));
385        }
386
387        match self.end_event.query() {
388            Ok(true) => {
389                // Kernel complete — compute elapsed time.
390                match Event::elapsed_time(&self.start_event, &self.end_event) {
391                    Ok(ms) => {
392                        let elapsed_us = f64::from(ms) * 1000.0;
393                        Poll::Ready(Ok(LaunchTiming { elapsed_us }))
394                    }
395                    Err(e) => Poll::Ready(Err(e)),
396                }
397            }
398            Ok(false) => {
399                let waker = cx.waker().clone();
400
401                if !self.poller_spawned || self.strategy == PollStrategy::Spin {
402                    self.poller_spawned = true;
403                    LaunchCompletion::spawn_poller(self.strategy, waker);
404                }
405
406                Poll::Pending
407            }
408            Err(e) => Poll::Ready(Err(e)),
409        }
410    }
411}
412
413// ---------------------------------------------------------------------------
414// AsyncKernel
415// ---------------------------------------------------------------------------
416
417/// A kernel wrapper with async launch capability.
418///
419/// Wraps a [`Kernel`] and provides methods that return [`Future`]s
420/// resolving when the GPU work completes.
421pub struct AsyncKernel {
422    /// The underlying kernel.
423    kernel: Kernel,
424    /// Configuration for async behaviour.
425    config: AsyncLaunchConfig,
426}
427
428impl AsyncKernel {
429    /// Creates a new `AsyncKernel` with default configuration.
430    #[inline]
431    pub fn new(kernel: Kernel) -> Self {
432        Self {
433            kernel,
434            config: AsyncLaunchConfig::default(),
435        }
436    }
437
438    /// Creates a new `AsyncKernel` with the given configuration.
439    #[inline]
440    pub fn with_config(kernel: Kernel, config: AsyncLaunchConfig) -> Self {
441        Self { kernel, config }
442    }
443
444    /// Returns a reference to the underlying [`Kernel`].
445    #[inline]
446    pub fn kernel(&self) -> &Kernel {
447        &self.kernel
448    }
449
450    /// Returns the kernel function name.
451    #[inline]
452    pub fn name(&self) -> &str {
453        self.kernel.name()
454    }
455
456    /// Returns a reference to the current [`AsyncLaunchConfig`].
457    #[inline]
458    pub fn config(&self) -> &AsyncLaunchConfig {
459        &self.config
460    }
461
462    /// Updates the async configuration.
463    #[inline]
464    pub fn set_config(&mut self, config: AsyncLaunchConfig) {
465        self.config = config;
466    }
467
468    /// Launches the kernel and returns a [`LaunchCompletion`] future.
469    ///
470    /// The kernel is launched asynchronously on the given stream, then
471    /// a CUDA event is recorded. The returned future polls that event
472    /// until it completes.
473    ///
474    /// # Errors
475    ///
476    /// Returns a [`CudaError`] if the kernel launch or event operations
477    /// fail. The future itself can also resolve to an error if the event
478    /// query fails later.
479    pub fn launch_async<A: KernelArgs>(
480        &self,
481        params: &LaunchParams,
482        stream: &Stream,
483        args: &A,
484    ) -> CudaResult<LaunchCompletion> {
485        // Launch the kernel.
486        self.kernel.launch(params, stream, args)?;
487
488        // Record an event after the launch.
489        let event = Event::new()?;
490        event.record(stream)?;
491
492        Ok(LaunchCompletion::new(event, &self.config))
493    }
494
495    /// Launches the kernel and returns a [`TimedLaunchCompletion`] future
496    /// that resolves to [`LaunchTiming`] with elapsed GPU time.
497    ///
498    /// Two events are recorded: one before and one after the kernel
499    /// launch. When the future resolves, the elapsed time between the
500    /// two events is computed.
501    ///
502    /// # Errors
503    ///
504    /// Returns a [`CudaError`] if the launch or event operations fail.
505    pub fn launch_and_time_async<A: KernelArgs>(
506        &self,
507        params: &LaunchParams,
508        stream: &Stream,
509        args: &A,
510    ) -> CudaResult<TimedLaunchCompletion> {
511        let start_event = Event::new()?;
512        start_event.record(stream)?;
513
514        self.kernel.launch(params, stream, args)?;
515
516        let end_event = Event::new()?;
517        end_event.record(stream)?;
518
519        Ok(TimedLaunchCompletion::new(
520            start_event,
521            end_event,
522            &self.config,
523        ))
524    }
525}
526
527impl std::fmt::Debug for AsyncKernel {
528    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
529        f.debug_struct("AsyncKernel")
530            .field("kernel", &self.kernel)
531            .field("config", &self.config)
532            .finish()
533    }
534}
535
536impl std::fmt::Display for AsyncKernel {
537    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
538        write!(f, "AsyncKernel({})", self.kernel.name())
539    }
540}
541
542// ---------------------------------------------------------------------------
543// multi_launch_async
544// ---------------------------------------------------------------------------
545
546/// Launches multiple kernels on the same stream and returns a combined
547/// [`LaunchCompletion`] future that resolves when **all** have finished.
548///
549/// A single event is recorded after all kernels have been enqueued,
550/// so the future resolves once the last kernel in the batch completes.
551///
552/// # Parameters
553///
554/// * `launches` — a slice of `(&Kernel, &LaunchParams, param_ptrs)` tuples.
555///   Each entry's `param_ptrs` is the result of calling
556///   [`KernelArgs::as_param_ptrs()`] on the kernel's arguments.
557/// * `stream` — the stream on which to enqueue all kernels.
558/// * `config` — async launch configuration.
559///
560/// # Errors
561///
562/// Returns the first [`CudaError`] encountered during any kernel launch
563/// or event operation.
564pub fn multi_launch_async(
565    launches: &[(&Kernel, &LaunchParams)],
566    args_list: &[&dyn ErasedKernelArgs],
567    stream: &Stream,
568    config: &AsyncLaunchConfig,
569) -> CudaResult<LaunchCompletion> {
570    for (i, (kernel, params)) in launches.iter().enumerate() {
571        let args = args_list.get(i).ok_or(CudaError::InvalidValue)?;
572        kernel.launch_erased(params, stream, *args)?;
573    }
574
575    let event = Event::new()?;
576    event.record(stream)?;
577
578    Ok(LaunchCompletion::new(event, config))
579}
580
581// ---------------------------------------------------------------------------
582// ErasedKernelArgs — object-safe wrapper
583// ---------------------------------------------------------------------------
584
585/// Object-safe trait for kernel arguments, enabling heterogeneous
586/// argument lists in [`multi_launch_async`].
587///
588/// # Safety
589///
590/// Implementors must ensure the returned pointers are valid for the
591/// duration of the kernel launch call.
592pub unsafe trait ErasedKernelArgs {
593    /// Convert arguments to void pointers.
594    fn erased_param_ptrs(&self) -> Vec<*mut std::ffi::c_void>;
595}
596
597/// Blanket implementation: every `KernelArgs` is also `ErasedKernelArgs`.
598///
599/// # Safety
600///
601/// Delegates to the underlying [`KernelArgs::as_param_ptrs`].
602unsafe impl<T: KernelArgs> ErasedKernelArgs for T {
603    #[inline]
604    fn erased_param_ptrs(&self) -> Vec<*mut std::ffi::c_void> {
605        self.as_param_ptrs()
606    }
607}
608
609// ---------------------------------------------------------------------------
610// Kernel::launch_erased — internal helper
611// ---------------------------------------------------------------------------
612
613impl Kernel {
614    /// Launches the kernel with erased (object-safe) arguments.
615    ///
616    /// This is an internal helper for [`multi_launch_async`].
617    pub(crate) fn launch_erased(
618        &self,
619        params: &LaunchParams,
620        stream: &Stream,
621        args: &dyn ErasedKernelArgs,
622    ) -> CudaResult<()> {
623        let driver = oxicuda_driver::loader::try_driver()?;
624        let mut param_ptrs = args.erased_param_ptrs();
625        oxicuda_driver::error::check(unsafe {
626            (driver.cu_launch_kernel)(
627                self.function().raw(),
628                params.grid.x,
629                params.grid.y,
630                params.grid.z,
631                params.block.x,
632                params.block.y,
633                params.block.z,
634                params.shared_mem_bytes,
635                stream.raw(),
636                param_ptrs.as_mut_ptr(),
637                std::ptr::null_mut(),
638            )
639        })
640    }
641}
642
643// ---------------------------------------------------------------------------
644// Tests
645// ---------------------------------------------------------------------------
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    // -- CompletionStatus tests --
652
653    #[test]
654    fn completion_status_is_complete() {
655        let status = CompletionStatus::Complete;
656        assert!(status.is_complete());
657        assert!(!status.is_pending());
658        assert!(!status.is_error());
659    }
660
661    #[test]
662    fn completion_status_is_pending() {
663        let status = CompletionStatus::Pending;
664        assert!(status.is_pending());
665        assert!(!status.is_complete());
666        assert!(!status.is_error());
667    }
668
669    #[test]
670    fn completion_status_is_error() {
671        let status = CompletionStatus::Error("test error".to_string());
672        assert!(status.is_error());
673        assert!(!status.is_complete());
674        assert!(!status.is_pending());
675    }
676
677    #[test]
678    fn completion_status_display() {
679        assert_eq!(CompletionStatus::Pending.to_string(), "Pending");
680        assert_eq!(CompletionStatus::Complete.to_string(), "Complete");
681        assert_eq!(
682            CompletionStatus::Error("oops".to_string()).to_string(),
683            "Error: oops"
684        );
685    }
686
687    #[test]
688    fn completion_status_eq() {
689        assert_eq!(CompletionStatus::Pending, CompletionStatus::Pending);
690        assert_eq!(CompletionStatus::Complete, CompletionStatus::Complete);
691        assert_ne!(CompletionStatus::Pending, CompletionStatus::Complete);
692        assert_eq!(
693            CompletionStatus::Error("a".into()),
694            CompletionStatus::Error("a".into())
695        );
696        assert_ne!(
697            CompletionStatus::Error("a".into()),
698            CompletionStatus::Error("b".into())
699        );
700    }
701
702    // -- PollStrategy tests --
703
704    #[test]
705    fn poll_strategy_default_is_yield() {
706        assert_eq!(PollStrategy::default(), PollStrategy::Yield);
707    }
708
709    #[test]
710    fn poll_strategy_backoff_value() {
711        let strategy = PollStrategy::BackoffMicros(100);
712        if let PollStrategy::BackoffMicros(us) = strategy {
713            assert_eq!(us, 100);
714        } else {
715            panic!("expected BackoffMicros");
716        }
717    }
718
719    // -- AsyncLaunchConfig tests --
720
721    #[test]
722    fn async_launch_config_default() {
723        let config = AsyncLaunchConfig::default();
724        assert_eq!(config.poll_strategy, PollStrategy::Yield);
725        assert!(config.timeout.is_none());
726    }
727
728    #[test]
729    fn async_launch_config_new() {
730        let config = AsyncLaunchConfig::new(PollStrategy::Spin);
731        assert_eq!(config.poll_strategy, PollStrategy::Spin);
732        assert!(config.timeout.is_none());
733    }
734
735    #[test]
736    fn async_launch_config_with_timeout() {
737        let config = AsyncLaunchConfig::new(PollStrategy::BackoffMicros(50))
738            .with_timeout(Duration::from_millis(500));
739        assert_eq!(config.poll_strategy, PollStrategy::BackoffMicros(50));
740        assert_eq!(config.timeout, Some(Duration::from_millis(500)));
741    }
742
743    // -- LaunchTiming tests --
744
745    #[test]
746    fn launch_timing_conversions() {
747        let timing = LaunchTiming {
748            elapsed_us: 1_500_000.0,
749        };
750        assert!((timing.elapsed_ms() - 1500.0).abs() < f64::EPSILON);
751        assert!((timing.elapsed_secs() - 1.5).abs() < f64::EPSILON);
752    }
753
754    #[test]
755    fn launch_timing_display_microseconds() {
756        let timing = LaunchTiming { elapsed_us: 42.5 };
757        let display = timing.to_string();
758        assert!(display.contains("us"), "expected 'us' in: {display}");
759    }
760
761    #[test]
762    fn launch_timing_display_milliseconds() {
763        let timing = LaunchTiming {
764            elapsed_us: 5_000.0,
765        };
766        let display = timing.to_string();
767        assert!(display.contains("ms"), "expected 'ms' in: {display}");
768    }
769
770    #[test]
771    fn launch_timing_display_seconds() {
772        let timing = LaunchTiming {
773            elapsed_us: 2_500_000.0,
774        };
775        let display = timing.to_string();
776        assert!(display.contains("s"), "expected 's' in: {display}");
777        assert!(
778            !display.contains("us"),
779            "should not contain 'us' in: {display}"
780        );
781        assert!(
782            !display.contains("ms"),
783            "should not contain 'ms' in: {display}"
784        );
785    }
786
787    #[test]
788    fn launch_timing_zero() {
789        let timing = LaunchTiming { elapsed_us: 0.0 };
790        assert!(timing.elapsed_ms().abs() < f64::EPSILON);
791        assert!(timing.elapsed_secs().abs() < f64::EPSILON);
792        assert!(timing.to_string().contains("us"));
793    }
794
795    // ---------------------------------------------------------------------------
796    // Quality gate tests (CPU-only)
797    // ---------------------------------------------------------------------------
798
799    #[test]
800    fn async_launch_status_pending_initially() {
801        // CompletionStatus::Pending represents "not yet completed".
802        // Verify initial/constructed status is Pending and passes is_pending().
803        let status = CompletionStatus::Pending;
804        assert!(status.is_pending(), "Newly created status must be Pending");
805        assert!(!status.is_complete());
806        assert!(!status.is_error());
807    }
808
809    #[test]
810    fn async_launch_debug_impl() {
811        // AsyncLaunchConfig implements Debug — verify it does not panic.
812        let config = AsyncLaunchConfig::new(PollStrategy::Yield);
813        let dbg = format!("{config:?}");
814        assert!(
815            dbg.contains("AsyncLaunchConfig"),
816            "Debug output must contain type name, got: {dbg}"
817        );
818        // PollStrategy also implements Debug
819        let strategy_dbg = format!("{:?}", PollStrategy::BackoffMicros(200));
820        assert!(
821            strategy_dbg.contains("BackoffMicros"),
822            "PollStrategy Debug must contain variant name, got: {strategy_dbg}"
823        );
824    }
825
826    #[test]
827    fn async_completion_event_created() {
828        // Creating an AsyncLaunchConfig produces a valid struct with the fields
829        // expected by the async launch machinery.
830        let config = AsyncLaunchConfig {
831            poll_strategy: PollStrategy::Spin,
832            timeout: Some(Duration::from_secs(5)),
833        };
834        assert_eq!(config.poll_strategy, PollStrategy::Spin);
835        assert_eq!(config.timeout, Some(Duration::from_secs(5)));
836
837        // with_timeout builder chain also works
838        let config2 = AsyncLaunchConfig::new(PollStrategy::BackoffMicros(100))
839            .with_timeout(Duration::from_millis(250));
840        assert_eq!(config2.poll_strategy, PollStrategy::BackoffMicros(100));
841        assert_eq!(config2.timeout, Some(Duration::from_millis(250)));
842    }
843}