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