Skip to main content

edgefirst_tensor/
cuda.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Optional CUDA Runtime interop, loaded via dlopen (no link-time dependency).
5//! Absent libcudart ⇒ every CUDA path degrades to `None`.
6//!
7//! The intended client pattern is a fast-fail + host fallback: call
8//! [`Tensor::cuda_map`](crate::Tensor::cuda_map) (or [`TensorDyn::cuda_map`](crate::TensorDyn::cuda_map))
9//! first; if it returns `None` (no CUDA handle attached or libcudart absent),
10//! fall back to the host mapping via `Tensor::map`. This keeps hot paths
11//! zero-copy on CUDA-capable hardware while remaining correct on CPU-only targets.
12use libloading::Library;
13use std::ffi::c_void;
14use std::os::raw::{c_int, c_uint};
15use std::sync::{Arc, OnceLock};
16
17pub(crate) type CudaError = c_int; // cudaSuccess == 0
18pub type GraphicsResource = *mut c_void; // cudaGraphicsResource_t
19pub(crate) type ExternalMemory = *mut c_void; // cudaExternalMemory_t
20pub type CudaStream = *mut c_void; // cudaStream_t
21
22#[allow(non_snake_case, dead_code)]
23pub(crate) struct CudaTable {
24    _lib: &'static Library,
25    pub graphics_gl_register_buffer:
26        unsafe extern "C" fn(*mut GraphicsResource, c_uint, c_uint) -> CudaError,
27    pub graphics_map_resources:
28        unsafe extern "C" fn(c_int, *mut GraphicsResource, *mut c_void) -> CudaError,
29    pub graphics_get_mapped_pointer:
30        unsafe extern "C" fn(*mut *mut c_void, *mut usize, GraphicsResource) -> CudaError,
31    pub graphics_unmap_resources:
32        unsafe extern "C" fn(c_int, *mut GraphicsResource, *mut c_void) -> CudaError,
33    pub graphics_unregister_resource: unsafe extern "C" fn(GraphicsResource) -> CudaError,
34    pub import_external_memory:
35        unsafe extern "C" fn(*mut ExternalMemory, *const c_void) -> CudaError,
36    pub external_memory_get_mapped_buffer:
37        unsafe extern "C" fn(*mut *mut c_void, ExternalMemory, *const c_void) -> CudaError,
38    pub destroy_external_memory: unsafe extern "C" fn(ExternalMemory) -> CudaError,
39    pub memcpy: unsafe extern "C" fn(*mut c_void, *const c_void, usize, c_int) -> CudaError,
40    pub free: unsafe extern "C" fn(*mut c_void) -> CudaError,
41    pub stream_create: unsafe extern "C" fn(*mut CudaStream) -> CudaError,
42    pub stream_synchronize: unsafe extern "C" fn(CudaStream) -> CudaError,
43    pub stream_destroy: unsafe extern "C" fn(CudaStream) -> CudaError,
44}
45
46static TABLE: OnceLock<Option<CudaTable>> = OnceLock::new();
47
48// Miri cannot execute `dlopen` -- it is a foreign function Miri's
49// interpreter refuses outright ("can't call foreign function `dlopen`"),
50// not merely a syscall it emulates differently. `Tensor::new` calls
51// `try_init_dma_cuda` unconditionally on Linux (`lib.rs`), which calls
52// `is_cuda_available` -> `table` -> `load` before it even checks whether
53// the tensor is DMA-backed, so this fires on every tensor construction
54// under Miri, DMA or not. Reporting "unavailable" here is not suppressing
55// a finding: this module's own contract is "absent libcudart => every CUDA
56// path degrades to `None`" (see the module doc), and under Miri libcudart
57// is, honestly, never going to be found.
58#[cfg(miri)]
59fn load() -> Option<CudaTable> {
60    None
61}
62
63#[cfg(not(miri))]
64fn load() -> Option<CudaTable> {
65    let lib = ["libcudart.so", "libcudart.so.12", "libcudart.so.11.0"]
66        .iter()
67        .find_map(|n| unsafe { Library::new(*n) }.ok())?;
68    let lib: &'static Library = Box::leak(Box::new(lib));
69    macro_rules! sym {
70        ($n:literal) => {{
71            *unsafe { lib.get(concat!($n, "\0").as_bytes()) }.ok()?
72        }};
73    }
74    Some(CudaTable {
75        _lib: lib,
76        graphics_gl_register_buffer: sym!("cudaGraphicsGLRegisterBuffer"),
77        graphics_map_resources: sym!("cudaGraphicsMapResources"),
78        graphics_get_mapped_pointer: sym!("cudaGraphicsResourceGetMappedPointer"),
79        graphics_unmap_resources: sym!("cudaGraphicsUnmapResources"),
80        graphics_unregister_resource: sym!("cudaGraphicsUnregisterResource"),
81        import_external_memory: sym!("cudaImportExternalMemory"),
82        external_memory_get_mapped_buffer: sym!("cudaExternalMemoryGetMappedBuffer"),
83        destroy_external_memory: sym!("cudaDestroyExternalMemory"),
84        memcpy: sym!("cudaMemcpy"),
85        free: sym!("cudaFree"),
86        stream_create: sym!("cudaStreamCreate"),
87        stream_synchronize: sym!("cudaStreamSynchronize"),
88        stream_destroy: sym!("cudaStreamDestroy"),
89    })
90}
91
92pub(crate) fn table() -> Option<&'static CudaTable> {
93    TABLE.get_or_init(load).as_ref()
94}
95
96/// True iff libcudart loaded and all interop symbols resolved. Cached, cheap.
97pub fn is_cuda_available() -> bool {
98    table().is_some()
99}
100
101/// `cudaMemcpyDeviceToHost` — copies from device (GPU) to host (CPU).
102pub const CUDA_MEMCPY_DEVICE_TO_HOST: c_int = 2;
103
104/// Copy `count` bytes from a CUDA device pointer to host. Returns `false` on
105/// failure or if libcudart is unavailable.
106///
107/// # Safety
108///
109/// The caller must ensure:
110/// - `host` points to at least `count` bytes of writable memory.
111/// - `device` points to at least `count` bytes of valid CUDA device memory
112///   (i.e. obtained from a `CudaMap::device_ptr()` while the map is live).
113pub unsafe fn memcpy_device_to_host(
114    host: *mut c_void,
115    device: *const c_void,
116    count: usize,
117) -> bool {
118    match table() {
119        Some(t) => unsafe { (t.memcpy)(host, device, count, CUDA_MEMCPY_DEVICE_TO_HOST) == 0 },
120        None => false,
121    }
122}
123
124/// Create a CUDA stream. Returns `None` if libcudart is unavailable or stream
125/// creation fails. The returned stream must be released with
126/// [`stream_destroy`]. Intended for clients (e.g. the codec's nvJPEG backend)
127/// that submit async device work and synchronise on it.
128pub fn stream_create() -> Option<CudaStream> {
129    let t = table()?;
130    let mut stream: CudaStream = std::ptr::null_mut();
131    if unsafe { (t.stream_create)(&mut stream) } != 0 {
132        return None;
133    }
134    Some(stream)
135}
136
137/// Block until all work submitted to `stream` completes. Returns `false` on
138/// failure or if libcudart is unavailable.
139///
140/// # Safety
141///
142/// `stream` must be a live stream returned by [`stream_create`] and not yet
143/// destroyed.
144pub unsafe fn stream_synchronize(stream: CudaStream) -> bool {
145    match table() {
146        Some(t) => unsafe { (t.stream_synchronize)(stream) == 0 },
147        None => false,
148    }
149}
150
151/// Destroy a CUDA stream. No-op if libcudart is unavailable.
152///
153/// # Safety
154///
155/// `stream` must be a live stream returned by [`stream_create`] and must not be
156/// used after this call.
157pub unsafe fn stream_destroy(stream: CudaStream) {
158    if let Some(t) = table() {
159        let _ = unsafe { (t.stream_destroy)(stream) };
160    }
161}
162
163/// Register a GL buffer (PBO) with CUDA. Returns the resource as `usize`
164/// (pointer) or `None`. MUST be called on the thread where the GL context is
165/// current.
166pub fn gl_register_buffer(buffer_id: u32) -> Option<usize> {
167    let t = table()?;
168    let mut res: GraphicsResource = std::ptr::null_mut();
169    // cudaGraphicsRegisterFlagsNone = 0
170    let rc = unsafe { (t.graphics_gl_register_buffer)(&mut res, buffer_id, 0) };
171    if rc != 0 {
172        log::debug!("cudaGraphicsGLRegisterBuffer(buffer={buffer_id}) failed: cudaError {rc}");
173        return None;
174    }
175    Some(res as usize)
176}
177
178/// Map a registered resource → `(device ptr as usize, size)`. GL-thread only.
179pub fn gl_map_resource(resource: usize) -> Option<(usize, usize)> {
180    let t = table()?;
181    let mut res = resource as GraphicsResource;
182    if unsafe { (t.graphics_map_resources)(1, &mut res, std::ptr::null_mut()) } != 0 {
183        return None;
184    }
185    let (mut ptr, mut size) = (std::ptr::null_mut::<c_void>(), 0usize);
186    if unsafe { (t.graphics_get_mapped_pointer)(&mut ptr, &mut size, res) } != 0 {
187        unsafe {
188            (t.graphics_unmap_resources)(1, &mut res, std::ptr::null_mut());
189        }
190        return None;
191    }
192    Some((ptr as usize, size))
193}
194
195/// Unmap a previously mapped resource. GL-thread only.
196pub fn gl_unmap_resource(resource: usize) {
197    if let Some(t) = table() {
198        let mut r = resource as GraphicsResource;
199        unsafe {
200            (t.graphics_unmap_resources)(1, &mut r, std::ptr::null_mut());
201        }
202    }
203}
204
205/// Unregister a previously registered resource. GL-thread only.
206pub fn gl_unregister_resource(resource: usize) {
207    if let Some(t) = table() {
208        unsafe {
209            (t.graphics_unregister_resource)(resource as GraphicsResource);
210        }
211    }
212}
213
214// =============================================================================
215// DMA-BUF → CUDA external memory import (thread-independent; no GL context).
216//
217// ABI verified against CUDA 12.6 driver_types.h, LP64, stable across CUDA
218// 11/12.  The structs are layout-asserted in the `ext_mem_layout` test module
219// below — no host with both /dev/dma_heap and CUDA is available in CI, so
220// runtime validation is deferred to on-target testing (orin-nano, gpu-probe O5
221// already confirmed cudaImportExternalMemory(OpaqueFd) works on Orin).
222// =============================================================================
223
224/// `cudaExternalMemoryHandleTypeOpaqueFd` — the only handle type used for
225/// Linux DMA-BUF fds. Value verified vs. driver_types.h for CUDA 11/12.
226#[allow(dead_code)] // only reached on Linux DMA tensors; kept cross-platform + ABI-tested
227pub(crate) const CUDA_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD: c_uint = 1;
228
229/// FFI mirror of `cudaExternalMemoryHandleDesc` (driver_types.h, LP64).
230///
231/// Layout (size 40, align 8):
232/// - `type_`       → int       @ 0
233/// - `_pad0`       → u32       @ 4  (align the union to 8)
234/// - `handle_fd`   → int       @ 8  (first member of the 16-byte union)
235/// - `_union_rest` → [u32; 3]  @ 12 (pads union to 16 bytes; ends at 24)
236/// - `size`        → u64       @ 24
237/// - `flags`       → c_uint    @ 32
238/// - `_tail`       → u32       @ 36 (struct size 40)
239#[allow(dead_code)] // only reached on Linux DMA tensors; kept cross-platform + ABI-tested
240#[repr(C)]
241pub(crate) struct CudaExternalMemoryHandleDesc {
242    pub type_: c_int,
243    pub _pad0: u32,
244    pub handle_fd: c_int,
245    pub _union_rest: [u32; 3],
246    pub size: u64,
247    pub flags: c_uint,
248    pub _tail: u32,
249}
250
251/// FFI mirror of `cudaExternalMemoryBufferDesc` (driver_types.h, LP64).
252///
253/// Layout (size 24, align 8):
254/// - `offset` → u64    @ 0
255/// - `size`   → u64    @ 8
256/// - `flags`  → c_uint @ 16
257/// - `_tail`  → u32    @ 20 (struct size 24)
258#[allow(dead_code)] // only reached on Linux DMA tensors; kept cross-platform + ABI-tested
259#[repr(C)]
260pub(crate) struct CudaExternalMemoryBufferDesc {
261    pub offset: u64,
262    pub size: u64,
263    pub flags: c_uint,
264    pub _tail: u32,
265}
266
267/// Import a DMA-BUF fd as CUDA external memory and map it to a device pointer.
268///
269/// Thread-independent — no GL context is required. `cudaImportExternalMemory`
270/// with OpaqueFd takes ownership of the fd it is given, so this function dups
271/// the caller's `fd` and hands CUDA the dup; the caller's `fd` is therefore
272/// untouched and remains owned by the caller. Returns `(ext_mem_handle,
273/// device_ptr)` on success, or `None` on any failure (missing libcudart,
274/// unsupported platform, dup failure, or driver error). The returned handle
275/// must be destroyed via `cudaDestroyExternalMemory` (done by [`CudaHandle`]
276/// drop), which also closes the dup'd fd.
277///
278/// # RUNTIME-UNVALIDATED
279/// No test platform has both `/dev/dma_heap` and a CUDA device. ABI is
280/// layout-asserted vs. CUDA 12.6 `driver_types.h`; the mechanism is proven
281/// by gpu-probe O5 on Orin. Best-effort: returns `None` on failure.
282// `static`-only: the sole caller, `Tensor::try_init_dma_cuda` (`lib.rs`), is
283// itself `#[cfg(feature = "static")]` (it reads `self.storage:
284// TensorStorage`, a `static`-only field). The rest of this module
285// (`CudaHandle`, `CudaMap`, `gl_register_buffer`, ...) stays available under
286// `dynamic` -- `dynamic_tensor::Tensor::cuda`/`cuda_map` return their types.
287#[cfg(all(target_os = "linux", feature = "static"))]
288pub(crate) fn import_dma_fd(fd: i32, size: usize) -> Option<(ExternalMemory, *mut c_void)> {
289    use std::os::fd::{AsRawFd, BorrowedFd, IntoRawFd};
290    let t = table()?;
291    // cudaExternalMemoryHandleTypeOpaqueFd TAKES OWNERSHIP of the fd on a
292    // successful import (CUDA closes it at cudaDestroyExternalMemory). The
293    // caller's fd is owned by TensorStorage::Dma and closed on tensor drop,
294    // so hand CUDA a dup to avoid a double-close.
295    // Keep the dup as OwnedFd until CUDA tells us whether it consumed it.
296    // `into_raw_fd` before the call left a window where a failed import that
297    // still closed the fd (observed on discrete NVIDIA + /dev/dma_heap) made
298    // `OwnedFd::from_raw_fd` + drop trip Rust's IO-safety abort.
299    let dup = unsafe { BorrowedFd::borrow_raw(fd) }
300        .try_clone_to_owned()
301        .ok()?;
302    let mut desc: CudaExternalMemoryHandleDesc = unsafe { std::mem::zeroed() };
303    desc.type_ = CUDA_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD as c_int;
304    desc.handle_fd = dup.as_raw_fd();
305    desc.size = size as u64;
306    let mut ext: ExternalMemory = std::ptr::null_mut();
307    let rc = unsafe { (t.import_external_memory)(&mut ext, &desc as *const _ as *const c_void) };
308    if rc != 0 {
309        log::debug!("cudaImportExternalMemory(OpaqueFd, size={size}) failed: cudaError {rc}");
310        // CUDA docs: a failed import does not consume the fd. Discrete NVIDIA
311        // drivers have been observed to close it anyway; reclaim via libc so
312        // an already-closed dup does not trip Rust's IO-safety abort.
313        let raw = dup.into_raw_fd();
314        if unsafe { libc::close(raw) } != 0 {
315            log::debug!(
316                "reclaim of CUDA-import dup fd {raw} after failed import: {}",
317                std::io::Error::last_os_error()
318            );
319        }
320        return None;
321    }
322    // Success: CUDA now owns the dup; it is closed by cudaDestroyExternalMemory.
323    let _ = dup.into_raw_fd();
324    let bdesc = CudaExternalMemoryBufferDesc {
325        offset: 0,
326        size: size as u64,
327        flags: 0,
328        _tail: 0,
329    };
330    let mut dptr: *mut c_void = std::ptr::null_mut();
331    if unsafe {
332        (t.external_memory_get_mapped_buffer)(&mut dptr, ext, &bdesc as *const _ as *const c_void)
333    } != 0
334    {
335        unsafe { (t.destroy_external_memory)(ext) };
336        return None;
337    }
338    Some((ext, dptr))
339}
340
341/// Routes `cudaGraphicsMapResources`/Unmap/Unregister through the GL worker
342/// thread (the GL context must be current there). Implemented by the image crate.
343pub trait CudaGlOps: Send + Sync {
344    fn map(&self, resource: GraphicsResource) -> Option<(*mut c_void, usize)>;
345    fn unmap(&self, resource: GraphicsResource);
346    fn unregister(&self, resource: GraphicsResource);
347}
348
349enum CudaBacking {
350    #[allow(dead_code)] // consumed by C3/C4
351    GlBuffer {
352        resource: GraphicsResource,
353        ops: Arc<dyn CudaGlOps>,
354    },
355    #[allow(dead_code)] // consumed by C3/C4
356    ExternalMem {
357        ext_mem: ExternalMemory,
358        dptr: *mut c_void,
359    },
360}
361
362// SAFETY: CUDA handles/ptrs are process-global; GlBuffer routes to the GL
363// worker; ExternalMem ptr is valid via the per-device primary context.
364unsafe impl Send for CudaBacking {}
365unsafe impl Sync for CudaBacking {}
366
367/// CUDA registration for a GPU-backed tensor. Held as `Option` on the tensor.
368pub struct CudaHandle {
369    kind: CudaBacking,
370    size: usize,
371}
372
373impl std::fmt::Debug for CudaHandle {
374    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
375        let kind = match &self.kind {
376            CudaBacking::GlBuffer { .. } => "GlBuffer",
377            CudaBacking::ExternalMem { .. } => "ExternalMem",
378        };
379        f.debug_struct("CudaHandle")
380            .field("kind", &kind)
381            .field("size", &self.size)
382            .finish()
383    }
384}
385
386impl CudaHandle {
387    /// Construct a GL-buffer-backed CUDA handle. `resource` is the
388    /// `cudaGraphicsResource_t` returned by [`gl_register_buffer`]; `ops`
389    /// routes map/unmap/unregister back to the GL-context thread.
390    pub fn new_gl(resource: GraphicsResource, size: usize, ops: Arc<dyn CudaGlOps>) -> Self {
391        Self {
392            kind: CudaBacking::GlBuffer { resource, ops },
393            size,
394        }
395    }
396
397    #[allow(dead_code)] // consumed by C3/C4
398    pub(crate) fn new_external(ext_mem: ExternalMemory, dptr: *mut c_void, size: usize) -> Self {
399        Self {
400            kind: CudaBacking::ExternalMem { ext_mem, dptr },
401            size,
402        }
403    }
404
405    /// Map to a device pointer. `GlBuffer` routes to the GL worker;
406    /// `ExternalMem` is persistent (no per-call map/unmap).
407    pub fn map(&self) -> Option<CudaMap<'_>> {
408        match &self.kind {
409            CudaBacking::GlBuffer { resource, ops } => {
410                let (ptr, len) = ops.map(*resource)?;
411                Some(CudaMap {
412                    ptr,
413                    len,
414                    unmap: Some((ops.clone(), *resource)),
415                    _marker: std::marker::PhantomData,
416                })
417            }
418            CudaBacking::ExternalMem { dptr, .. } => Some(CudaMap {
419                ptr: *dptr,
420                len: self.size,
421                unmap: None,
422                _marker: std::marker::PhantomData,
423            }),
424        }
425    }
426}
427
428impl Drop for CudaHandle {
429    fn drop(&mut self) {
430        match &self.kind {
431            CudaBacking::GlBuffer { resource, ops } => ops.unregister(*resource),
432            CudaBacking::ExternalMem { ext_mem, dptr: _ } => {
433                // The device pointer comes from `cudaExternalMemoryGetMappedBuffer`,
434                // which CUDA frees together with the external-memory object. Calling
435                // `cudaFree` on such a pointer is explicitly disallowed and corrupts
436                // the driver's bookkeeping (risking a double-free when the handle is
437                // destroyed), so only destroy the external-memory object here.
438                if let Some(t) = table() {
439                    unsafe {
440                        (t.destroy_external_memory)(*ext_mem);
441                    }
442                }
443            }
444        }
445    }
446}
447
448/// Scoped CUDA device-pointer mapping. `Drop` unmaps a `GlBuffer` (so GL may
449/// reuse the PBO for the next `convert()` call). `ExternalMem` mappings are
450/// persistent — `Drop` is a no-op.
451pub struct CudaMap<'a> {
452    ptr: *mut c_void,
453    len: usize,
454    unmap: Option<(Arc<dyn CudaGlOps>, GraphicsResource)>,
455    _marker: std::marker::PhantomData<&'a ()>,
456}
457
458// SAFETY: the mapped device pointer is process-global and valid cross-thread
459// via the per-device CUDA primary context; the routed CudaGlOps is Send+Sync.
460// Required so callers can hold the guard on a separate inference thread.
461unsafe impl Send for CudaMap<'_> {}
462unsafe impl Sync for CudaMap<'_> {}
463
464impl CudaMap<'_> {
465    /// Raw device pointer to the mapped buffer.
466    pub fn device_ptr(&self) -> *mut c_void {
467        self.ptr
468    }
469
470    /// Length of the mapping in bytes.
471    pub fn len(&self) -> usize {
472        self.len
473    }
474
475    /// Returns `true` if the mapping covers zero bytes.
476    pub fn is_empty(&self) -> bool {
477        self.len == 0
478    }
479}
480
481impl Drop for CudaMap<'_> {
482    fn drop(&mut self) {
483        if let Some((ops, r)) = self.unmap.take() {
484            ops.unmap(r);
485        }
486    }
487}
488
489#[cfg(test)]
490mod ext_mem_layout {
491    use super::*;
492    #[test]
493    fn external_memory_desc_abi() {
494        assert_eq!(std::mem::size_of::<CudaExternalMemoryHandleDesc>(), 40);
495        assert_eq!(std::mem::align_of::<CudaExternalMemoryHandleDesc>(), 8);
496        // size field at offset 24, flags at 32 (verified vs driver_types.h)
497        let d: CudaExternalMemoryHandleDesc = unsafe { std::mem::zeroed() };
498        let base = &d as *const _ as usize;
499        assert_eq!((&d.size as *const _ as usize) - base, 24);
500        assert_eq!((&d.flags as *const _ as usize) - base, 32);
501        assert_eq!(std::mem::size_of::<CudaExternalMemoryBufferDesc>(), 24);
502        let b: CudaExternalMemoryBufferDesc = unsafe { std::mem::zeroed() };
503        let bb = &b as *const _ as usize;
504        assert_eq!((&b.size as *const _ as usize) - bb, 8);
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    #[test]
512    fn cuda_table_loads_when_libcudart_present() {
513        let avail = is_cuda_available();
514        if avail {
515            assert!(table().is_some(), "table present when available");
516        }
517        // total + non-panicking either way
518    }
519
520    #[test]
521    fn pub_primitives_degrade_without_libcudart() {
522        // On hosts without libcudart (all CI coverage lanes), the public CUDA
523        // primitives must degrade cleanly — None / false / no-op, never panic.
524        // Skip on a CUDA host, where these would touch the real driver without
525        // a current GL context.
526        if is_cuda_available() {
527            return;
528        }
529        assert!(gl_register_buffer(0).is_none());
530        assert!(gl_map_resource(0).is_none());
531        gl_unmap_resource(0); // no-op without a table — must not panic
532        gl_unregister_resource(0); // no-op without a table — must not panic
533                                   // SAFETY: with no libcudart, memcpy_device_to_host returns false before
534                                   // touching the pointers, so null/zero args are sound here.
535        assert!(!unsafe { memcpy_device_to_host(std::ptr::null_mut(), std::ptr::null(), 0) });
536    }
537}
538
539#[cfg(test)]
540mod handle_tests {
541    use super::*;
542    use std::sync::{
543        atomic::{AtomicUsize, Ordering},
544        Arc,
545    };
546    struct MockOps {
547        unmaps: Arc<AtomicUsize>,
548        unregisters: Arc<AtomicUsize>,
549    }
550    impl CudaGlOps for MockOps {
551        fn map(&self, _r: GraphicsResource) -> Option<(*mut std::ffi::c_void, usize)> {
552            Some((0x1000usize as *mut _, 4096))
553        }
554        fn unmap(&self, _r: GraphicsResource) {
555            self.unmaps.fetch_add(1, Ordering::SeqCst);
556        }
557        fn unregister(&self, _r: GraphicsResource) {
558            self.unregisters.fetch_add(1, Ordering::SeqCst);
559        }
560    }
561    #[test]
562    fn cudamap_guard_unmaps_on_drop_for_glbuffer() {
563        let unmaps = Arc::new(AtomicUsize::new(0));
564        let unregisters = Arc::new(AtomicUsize::new(0));
565        {
566            let h = CudaHandle::new_gl(
567                0x1usize as GraphicsResource,
568                4096,
569                Arc::new(MockOps {
570                    unmaps: unmaps.clone(),
571                    unregisters: unregisters.clone(),
572                }),
573            );
574            {
575                let m = h.map().expect("map");
576                assert_eq!(m.device_ptr() as usize, 0x1000);
577                assert_eq!(m.len(), 4096);
578                assert!(!m.is_empty());
579            }
580            // CudaMap dropped → exactly one unmap; handle still alive → no unregister yet.
581            assert_eq!(
582                unmaps.load(Ordering::SeqCst),
583                1,
584                "Drop must unmap a GlBuffer"
585            );
586            assert_eq!(unregisters.load(Ordering::SeqCst), 0);
587        }
588        // CudaHandle dropped → exactly one unregister.
589        assert_eq!(
590            unregisters.load(Ordering::SeqCst),
591            1,
592            "Dropping a GlBuffer handle must unregister"
593        );
594    }
595
596    /// A GlBuffer handle whose ops.map() fails yields None from CudaHandle::map.
597    struct NoneOps;
598    impl CudaGlOps for NoneOps {
599        fn map(&self, _r: GraphicsResource) -> Option<(*mut std::ffi::c_void, usize)> {
600            None
601        }
602        fn unmap(&self, _r: GraphicsResource) {}
603        fn unregister(&self, _r: GraphicsResource) {}
604    }
605    #[test]
606    fn glbuffer_map_returns_none_when_ops_map_fails() {
607        let h = CudaHandle::new_gl(0x9usize as GraphicsResource, 4096, Arc::new(NoneOps));
608        assert!(
609            h.map().is_none(),
610            "GlBuffer map propagates ops.map() failure"
611        );
612    }
613
614    #[test]
615    fn glbuffer_handle_debug_and_empty_map() {
616        let unmaps = Arc::new(AtomicUsize::new(0));
617        let unregisters = Arc::new(AtomicUsize::new(0));
618        let h = CudaHandle::new_gl(
619            0x2usize as GraphicsResource,
620            0,
621            Arc::new(MockOps {
622                unmaps: unmaps.clone(),
623                unregisters: unregisters.clone(),
624            }),
625        );
626        let dbg = format!("{h:?}");
627        assert!(
628            dbg.contains("GlBuffer"),
629            "debug names the backing kind: {dbg}"
630        );
631        assert!(dbg.contains("size"), "debug includes size: {dbg}");
632    }
633
634    #[test]
635    fn external_mem_map_is_persistent_and_debug_names_kind() {
636        // ExternalMem handle: map() returns the persistent device ptr directly
637        // (no GL routing, unmap is a no-op). Construct with a synthetic ptr.
638        let dptr = 0xCAFE_0000usize as *mut std::ffi::c_void;
639        let h = CudaHandle::new_external(std::ptr::null_mut(), dptr, 8192);
640        let dbg = format!("{h:?}");
641        assert!(dbg.contains("ExternalMem"), "debug names the kind: {dbg}");
642        {
643            let m = h.map().expect("ExternalMem map is always Some");
644            assert_eq!(m.device_ptr(), dptr, "persistent device ptr passthrough");
645            assert_eq!(m.len(), 8192);
646            assert!(!m.is_empty());
647            // CudaMap drops here: ExternalMem mapping has unmap=None → no-op, safe.
648        }
649        // HOST-SAFETY: dropping `h` would call the real cudaDestroyExternalMemory
650        // on this synthetic handle (libcudart is present on dev hosts). Forget it.
651        std::mem::forget(h);
652    }
653
654    #[test]
655    fn external_mem_zero_len_map_is_empty() {
656        let h = CudaHandle::new_external(std::ptr::null_mut(), std::ptr::null_mut(), 0);
657        {
658            let m = h.map().expect("map");
659            assert_eq!(m.len(), 0);
660            assert!(m.is_empty(), "zero-length mapping is empty");
661            assert!(m.device_ptr().is_null());
662        }
663        std::mem::forget(h); // HOST-SAFETY: avoid real cudaDestroyExternalMemory
664    }
665}