Skip to main content

onnx_runtime_ep_api/
provider.rs

1//! The [`ExecutionProvider`] trait and its supporting types (§4.1).
2
3use std::ffi::c_void;
4use std::ptr::NonNull;
5
6use onnx_runtime_ir::{DataType, DeviceId, DeviceType, Graph, Node, NodeId, Shape, TensorLayout};
7
8use crate::epcontext::EpContext;
9use crate::error::{EpError, Result};
10use crate::kernel::{Kernel, KernelMatch};
11use crate::weight::ExecutionProviderCapabilities;
12
13/// Index of an EP within an [`crate::registry::EpRegistry`].
14#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
15pub struct EpId(pub u32);
16
17/// Opaque, namespaced configuration passed to [`ExecutionProvider::initialize`].
18#[derive(Clone, Debug, Default)]
19pub struct EpConfig {
20    /// Namespaced key/value options (e.g. `"cuda.arena_extend_strategy"`).
21    pub options: std::collections::HashMap<String, String>,
22}
23
24/// An owning handle to a single device allocation.
25///
26/// # Ownership & lifetime
27///
28/// A `DeviceBuffer` is the **sole owner** of the allocation it names. It is
29/// produced only by [`ExecutionProvider::allocate`] and released only by
30/// [`ExecutionProvider::deallocate`], which consumes it *by value*. The owning
31/// EP is both allocator and deallocator: the buffer records the [`DeviceId`]
32/// (hence which EP instance) that may free it, so a buffer must never be handed
33/// to a different EP. Ownership is unique — no two `DeviceBuffer`s ever alias
34/// the same allocation.
35///
36/// # No `Drop`
37///
38/// `DeviceBuffer` deliberately does **not** implement [`Drop`]. Freeing device
39/// memory generally needs the EP's context/stream (a CUDA context, an MLX
40/// queue, an allocator arena) that this bare handle does not carry, so a silent
41/// drop could not free correctly. Consequences:
42/// * Dropping a `DeviceBuffer` without passing it to `deallocate` **leaks** the
43///   allocation. It can never *double-free*, which is the memory-safety
44///   property we prioritize (plan §4.4).
45/// * The session layer owns the discipline of pairing every `allocate` with
46///   exactly one `deallocate`. Higher layers may wrap this handle in an
47///   RAII/`Arc` type that calls back into the EP; that policy lives above the
48///   EP contract, not here.
49///
50/// # Access
51///
52/// The base address is reachable only through [`DeviceBuffer::as_ptr`]
53/// (shared) and [`DeviceBuffer::as_mut_ptr`] (unique). Obtaining a pointer is
54/// safe; *dereferencing* it is `unsafe` and valid only on host-accessible
55/// devices ([`DeviceType::is_host_accessible`]) within the owning EP's context.
56///
57/// # Thread-safety
58///
59/// See the `Send`/`Sync` impls below for the exact invariant.
60#[derive(Debug)]
61pub struct DeviceBuffer {
62    device: DeviceId,
63    size: usize,
64    align: usize,
65    /// Non-null base address of the allocation. For CPU and MLX unified memory
66    /// this is a dereferenceable host pointer; for CUDA/ROCm it is an opaque
67    /// device address only meaningful inside the owning EP's context.
68    ptr: NonNull<c_void>,
69    /// Whether this handle *owns* the pointed-to allocation.
70    ///
71    /// [`BufferOwner::Owned`] (the default for [`DeviceBuffer::from_raw_parts`])
72    /// is the original contract: the owning EP must free it exactly once in
73    /// `deallocate`. Borrowed handles alias memory owned by *someone else*.
74    /// Read-only aliases come from [`DeviceBuffer::from_borrowed_parts`];
75    /// exclusive writable aliases come from
76    /// [`DeviceBuffer::from_borrowed_mut_parts`]. `deallocate` must **not** free
77    /// either kind.
78    owner: BufferOwner,
79}
80
81/// Whether a [`DeviceBuffer`] owns the allocation it names, or merely borrows
82/// (aliases) memory owned elsewhere.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84enum BufferOwner {
85    /// This handle is the sole owner; the owning EP frees it in `deallocate`.
86    Owned,
87    /// This handle aliases foreign memory (e.g. an mmap). `deallocate` must be
88    /// a no-op free; the real owner must outlive the buffer and every use of it.
89    Borrowed,
90    /// This handle has temporary exclusive write access to an allocation owned
91    /// elsewhere. Deallocation remains a no-op.
92    BorrowedMut,
93}
94
95impl DeviceBuffer {
96    /// Wrap a raw device allocation in an owning handle.
97    ///
98    /// # Safety
99    ///
100    /// The caller (the owning EP) must guarantee all of:
101    /// * `ptr` is non-null and points to the start of an allocation of at least
102    ///   `size` bytes on `device`, aligned to at least `align` bytes.
103    /// * The allocation was produced by `device`'s EP and will be freed exactly
104    ///   once, only by returning this handle to that EP's `deallocate` (or via
105    ///   an equivalent raw free of the pointer obtained from
106    ///   [`DeviceBuffer::into_raw`]).
107    /// * No other live `DeviceBuffer` aliases the same allocation.
108    ///
109    /// `align` must be a power of two (checked in debug builds).
110    pub unsafe fn from_raw_parts(
111        ptr: *mut c_void,
112        device: DeviceId,
113        size: usize,
114        align: usize,
115    ) -> Self {
116        debug_assert!(align.is_power_of_two(), "alignment must be a power of two");
117        Self {
118            device,
119            size,
120            align,
121            ptr: NonNull::new(ptr).expect("DeviceBuffer::from_raw_parts: null pointer"),
122            owner: BufferOwner::Owned,
123        }
124    }
125
126    /// Wrap **foreign, borrowed** memory in a non-owning `DeviceBuffer`.
127    ///
128    /// Unlike [`DeviceBuffer::from_raw_parts`], the returned handle does **not**
129    /// own the allocation: it aliases memory owned by someone else (for example
130    /// a `memmap2::Mmap` over an on-disk weight file). This lets an EP reference
131    /// initializer bytes zero-copy instead of allocating + copying them into
132    /// fresh RAM.
133    ///
134    /// [`is_borrowed`](DeviceBuffer::is_borrowed) returns `true`, and the owning
135    /// EP's `deallocate` must treat it as a **no-op free** (the guard checks
136    /// `is_borrowed()`). [`into_raw`](DeviceBuffer::into_raw) still yields the
137    /// raw pointer, but the caller must **not** free it.
138    ///
139    /// # Safety
140    ///
141    /// The caller must guarantee all of:
142    /// * `ptr` is non-null and points to the start of a readable region of at
143    ///   least `size` bytes on `device`, aligned to at least `align` bytes.
144    /// * The memory is owned by another object (e.g. an mmap) that **outlives
145    ///   this buffer and every use of it** (read via `as_ptr`). Nothing else may
146    ///   free or unmap it while this handle or any alias derived from it lives.
147    /// * The buffer is treated as **read-only**: it is never written through
148    ///   (`as_mut_ptr` must not be used to mutate borrowed memory) and is never
149    ///   passed to an EP's `deallocate` expecting a free — `deallocate` skips
150    ///   the free for borrowed buffers.
151    ///
152    /// `align` must be a power of two (checked in debug builds).
153    pub unsafe fn from_borrowed_parts(
154        ptr: *mut c_void,
155        device: DeviceId,
156        size: usize,
157        align: usize,
158    ) -> Self {
159        debug_assert!(align.is_power_of_two(), "alignment must be a power of two");
160        Self {
161            device,
162            size,
163            align,
164            ptr: NonNull::new(ptr).expect("DeviceBuffer::from_borrowed_parts: null pointer"),
165            owner: BufferOwner::Borrowed,
166        }
167    }
168
169    /// Wrap foreign memory in a non-owning, exclusively writable buffer handle.
170    ///
171    /// This is intended for persistent external output bindings: the real owner
172    /// retains the allocation while an executor temporarily writes through this
173    /// alias.
174    ///
175    /// # Safety
176    ///
177    /// The caller must guarantee all of:
178    /// * `ptr` names a non-null writable allocation of at least `size` bytes on
179    ///   `device`, aligned to at least `align` bytes.
180    /// * The real owner outlives this handle and every operation using it.
181    /// * No other writer accesses the allocation while this handle is live.
182    /// * This handle is never used to free the allocation; `deallocate` treats
183    ///   it as borrowed.
184    pub unsafe fn from_borrowed_mut_parts(
185        ptr: *mut c_void,
186        device: DeviceId,
187        size: usize,
188        align: usize,
189    ) -> Option<Self> {
190        debug_assert!(align.is_power_of_two(), "alignment must be a power of two");
191        Some(Self {
192            device,
193            size,
194            align,
195            ptr: NonNull::new(ptr)?,
196            owner: BufferOwner::BorrowedMut,
197        })
198    }
199
200    /// Whether this handle merely *borrows* (aliases) foreign memory rather than
201    /// owning it. A borrowed buffer must never be freed by `deallocate`.
202    pub fn is_borrowed(&self) -> bool {
203        matches!(self.owner, BufferOwner::Borrowed | BufferOwner::BorrowedMut)
204    }
205
206    /// The device this allocation lives on (and whose EP must free it).
207    pub fn device(&self) -> DeviceId {
208        self.device
209    }
210
211    /// Allocation size in bytes.
212    pub fn len(&self) -> usize {
213        self.size
214    }
215
216    /// Whether the allocation is zero-length.
217    pub fn is_empty(&self) -> bool {
218        self.size == 0
219    }
220
221    /// Alignment (bytes) the base pointer was allocated to.
222    pub fn alignment(&self) -> usize {
223        self.align
224    }
225
226    /// Shared base pointer. Safe to obtain; dereferencing is `unsafe` and only
227    /// sound on host-accessible devices within the owning EP's context.
228    pub fn as_ptr(&self) -> *const c_void {
229        self.ptr.as_ptr()
230    }
231
232    /// Unique mutable base pointer. Requires `&mut self` so the borrow checker
233    /// forbids two writers sharing one buffer — this is what makes the `Sync`
234    /// impl sound (a shared `&DeviceBuffer` can never hand out a writable
235    /// pointer through safe code).
236    pub fn as_mut_ptr(&mut self) -> *mut c_void {
237        self.ptr.as_ptr()
238    }
239
240    /// Consume the handle, returning the raw pointer *without* freeing it. For
241    /// an owned buffer the caller assumes the single-free obligation from
242    /// [`DeviceBuffer::from_raw_parts`]. For a **borrowed** buffer (see
243    /// [`DeviceBuffer::from_borrowed_parts`]) the pointer must **not** be freed;
244    /// check [`is_borrowed`](DeviceBuffer::is_borrowed) first if the caller
245    /// intends to free.
246    pub fn into_raw(self) -> *mut c_void {
247        self.ptr.as_ptr()
248    }
249}
250
251// SAFETY: `DeviceBuffer` is an owning *handle* — it stores only a base address
252// plus metadata and exposes no safe way to read or write the pointed-to memory
253// (all access goes through `as_ptr`/`as_mut_ptr`, which are safe to *call* but
254// `unsafe` to *use*). Moving the handle to another thread transfers ownership of
255// the address; this is sound for every allocator we target — host `malloc`,
256// CUDA device pointers, and MLX unified memory are all address-portable and not
257// thread-affine at the pointer level. Any data race on the *contents* is
258// prevented one layer up by `&`/`&mut` aliasing on `TensorView`/`TensorMut` and
259// by the scheduler, not by this type. If a future EP wires a genuinely
260// thread-affine allocator, it must wrap the handle in a non-`Send` owner rather
261// than weaken this invariant (plan §4.4 flags this for a dedicated review when
262// ep-cpu lands real memory).
263unsafe impl Send for DeviceBuffer {}
264// SAFETY: `&DeviceBuffer` grants no interior mutability — it can only produce a
265// `*const` via `as_ptr` (a plain address copy) and read `Copy` metadata, so
266// concurrent shared reads of the handle are race-free. Writing requires
267// `as_mut_ptr`, which needs `&mut self`; obtaining a writable pointer therefore
268// cannot happen through a shared reference in safe code. As with `Send`,
269// mutating the underlying memory is gated behind `unsafe` pointer use whose
270// synchronization is the caller's responsibility.
271unsafe impl Sync for DeviceBuffer {}
272
273/// A synchronization fence returned by async operations.
274#[derive(Debug, Default)]
275pub struct Fence {
276    pub id: u64,
277}
278
279/// Marker for an EP exported as an ORT-compatible C ABI plugin (Phase 2).
280#[derive(Debug, Default)]
281pub struct OrtPluginExport {
282    pub register_symbol: String,
283}
284
285/// The core EP interface. Every backend crate implements this (§4.1).
286pub trait ExecutionProvider: Send + Sync {
287    /// EP identifier (snake_case, e.g. `"cpu_ep"`, `"cuda_ep"`).
288    fn name(&self) -> &str;
289
290    fn device_type(&self) -> DeviceType;
291    fn device_id(&self) -> DeviceId;
292
293    /// Optional executor-to-EP capabilities. Stock EPs advertise none and
294    /// continue receiving resident [`crate::TensorView`] inputs.
295    fn capabilities(&self) -> ExecutionProviderCapabilities {
296        ExecutionProviderCapabilities::stock()
297    }
298
299    /// Initialize device resources / load libraries.
300    fn initialize(&mut self, config: &EpConfig) -> Result<()>;
301    /// Release device resources.
302    fn shutdown(&mut self) -> Result<()>;
303
304    /// Whether this EP can run `op` at the model's effective `opset` with the
305    /// given input shapes, dtypes, and layouts, and at what cost.
306    ///
307    /// Every [`KernelMatch::Unsupported`] result must carry an actionable reason:
308    /// state what the EP accepts and, where possible, how to fix the model or
309    /// registration rather than returning a bare decline.
310    fn supports_op(
311        &self,
312        op: &Node,
313        opset: u64,
314        shapes: &[Shape],
315        input_dtypes: &[DataType],
316        layouts: &[TensorLayout],
317    ) -> KernelMatch;
318
319    /// Get or create a kernel for `op` specialized to concrete `shapes`.
320    ///
321    /// `opset` is the effective operator-set version for `op`'s domain in the
322    /// owning graph. EPs use it to select opset-specialized kernels (e.g. the
323    /// opset-13 per-axis vs. the legacy opset-<13 2D-coercion `Softmax`).
324    fn get_kernel(&self, op: &Node, shapes: &[Vec<usize>], opset: u64) -> Result<Box<dyn Kernel>>;
325
326    /// Allocate device memory.
327    fn allocate(&self, size: usize, alignment: usize) -> Result<DeviceBuffer>;
328    /// Free device memory.
329    fn deallocate(&self, buffer: DeviceBuffer) -> Result<()>;
330
331    /// Synchronous copy (host↔device or device↔device).
332    fn copy(&self, src: &DeviceBuffer, dst: &mut DeviceBuffer, size: usize) -> Result<()>;
333    /// Asynchronous copy; returns a [`Fence`] to await.
334    fn copy_async(&self, src: &DeviceBuffer, dst: &mut DeviceBuffer, size: usize) -> Result<Fence>;
335
336    /// Whether this EP can select the first maximum f32 element on-device and
337    /// return the token id together with its capture-error status.
338    fn device_argmax_supported(&self) -> bool {
339        false
340    }
341
342    /// Launch an allocation-free device argmax over `elements` contiguous
343    /// `dtype` values (Float32 or Float16). `result` receives two native-endian
344    /// u32 values: token id, then the latching device capture-error bitmask.
345    fn device_argmax(
346        &self,
347        _logits: &DeviceBuffer,
348        _elements: usize,
349        _dtype: DataType,
350        _result: &mut DeviceBuffer,
351    ) -> Result<()> {
352        Err(EpError::KernelFailed(format!(
353            "{}: device argmax is not supported",
354            self.name()
355        )))
356    }
357
358    /// Begin recording the supplied, already-compiled kernel sequence into a
359    /// device graph. EPs without graph support reject the request.
360    fn begin_device_graph_capture(&self, _kernels: &[&dyn Kernel]) -> Result<()> {
361        Err(EpError::KernelFailed(format!(
362            "{}: device graph capture is not supported",
363            self.name()
364        )))
365    }
366
367    /// End device-graph capture and install the resulting executable.
368    fn end_device_graph_capture(&self) -> Result<()> {
369        Err(EpError::KernelFailed(format!(
370            "{}: device graph capture is not supported",
371            self.name()
372        )))
373    }
374
375    /// Abort an in-progress device-graph capture, returning the stream and
376    /// lifecycle to a clean idle state so a subsequent [`reset_device_graph`]
377    /// succeeds. Called on the error path of segmented capture when a node
378    /// fails mid-record: the capture must always be ended before reset, so the
379    /// stream is not left wedged in capture mode. EPs without device graphs have
380    /// nothing to abort.
381    ///
382    /// [`reset_device_graph`]: ExecutionProvider::reset_device_graph
383    fn abort_device_graph_capture(&self) -> Result<()> {
384        Ok(())
385    }
386
387    /// Replay the installed device graph.
388    ///
389    /// When the EP holds multiple captured **segments** (segmented capture), this
390    /// replays every installed segment in capture order. For the single-graph
391    /// fast path (one whole-subgraph capture) that is exactly the one graph.
392    fn replay_device_graph(&self) -> Result<()> {
393        Err(EpError::KernelFailed(format!(
394            "{}: device graph replay is not supported",
395            self.name()
396        )))
397    }
398
399    /// Replay one captured **segment** by its zero-based capture-order index.
400    ///
401    /// Segmented capture claims a whole subgraph even when only parts are
402    /// device-graph capturable: the executor captures each maximal capturable
403    /// run as its own segment and, at replay time, launches the segment graphs
404    /// in order while running the non-capturable seam nodes eagerly in between.
405    /// EPs without segmented graph support reject the request.
406    fn replay_device_graph_segment(&self, _index: usize) -> Result<()> {
407        Err(EpError::KernelFailed(format!(
408            "{}: segmented device graph replay is not supported",
409            self.name()
410        )))
411    }
412
413    /// Destroy any installed device graph before its referenced buffers move or
414    /// are released.
415    fn reset_device_graph(&self) -> Result<bool> {
416        Ok(false)
417    }
418
419    /// Read (without clearing) any latching device-side capture-safety error a
420    /// captured kernel recorded during graph replay, as a raw violation bitmask
421    /// (zero when none). EPs without device graphs report no error.
422    ///
423    /// The decode loop calls this at the per-step logits device→host sync so an
424    /// out-of-range bounds violation becomes a hard error before the produced
425    /// token is consumed, without adding a separate synchronization.
426    fn check_device_capture_error(&self) -> Result<u32> {
427        Ok(0)
428    }
429
430    /// Explicit device allocation/free counters, when the EP exposes them.
431    fn device_allocation_counts(&self) -> Option<(u64, u64)> {
432        None
433    }
434
435    /// Synchronously upload host bytes into a buffer owned by this EP.
436    fn copy_from_host(&self, src: &[u8], dst: &mut DeviceBuffer) -> Result<()> {
437        if !dst.device().is_host_accessible() {
438            return Err(EpError::KernelFailed(format!(
439                "{}: host upload is not implemented for device {:?}",
440                self.name(),
441                dst.device()
442            )));
443        }
444        if src.len() > dst.len() {
445            return Err(EpError::KernelFailed(format!(
446                "{}: host upload of {} bytes exceeds destination {} bytes",
447                self.name(),
448                src.len(),
449                dst.len()
450            )));
451        }
452        if src.is_empty() {
453            return Ok(());
454        }
455        // SAFETY: host accessibility is checked above, `dst` is uniquely
456        // borrowed, and its allocation is at least `src.len()` bytes.
457        unsafe {
458            std::ptr::copy_nonoverlapping(src.as_ptr(), dst.as_mut_ptr().cast(), src.len());
459        }
460        Ok(())
461    }
462
463    /// Synchronously upload host bytes into a byte range of a buffer owned by
464    /// this EP.
465    fn copy_from_host_at(
466        &self,
467        src: &[u8],
468        dst: &mut DeviceBuffer,
469        byte_offset: usize,
470    ) -> Result<()> {
471        let end = byte_offset.checked_add(src.len()).ok_or_else(|| {
472            EpError::KernelFailed(format!("{}: host upload range overflows", self.name()))
473        })?;
474        if end > dst.len() {
475            return Err(EpError::KernelFailed(format!(
476                "{}: host upload range {byte_offset}..{end} exceeds destination {} bytes",
477                self.name(),
478                dst.len()
479            )));
480        }
481        if src.is_empty() {
482            return Ok(());
483        }
484        if !dst.device().is_host_accessible() {
485            return Err(EpError::KernelFailed(format!(
486                "{}: ranged host upload is not implemented for device {:?}",
487                self.name(),
488                dst.device()
489            )));
490        }
491        // SAFETY: host accessibility and bounds are checked above, and `dst` is
492        // uniquely borrowed for the duration of the copy.
493        unsafe {
494            std::ptr::copy_nonoverlapping(
495                src.as_ptr(),
496                dst.as_mut_ptr().cast::<u8>().add(byte_offset),
497                src.len(),
498            );
499        }
500        Ok(())
501    }
502
503    /// Synchronously download a buffer owned by this EP into host bytes.
504    fn copy_to_host(&self, src: &DeviceBuffer, dst: &mut [u8]) -> Result<()> {
505        if !src.device().is_host_accessible() {
506            return Err(EpError::KernelFailed(format!(
507                "{}: host download is not implemented for device {:?}",
508                self.name(),
509                src.device()
510            )));
511        }
512        if dst.len() > src.len() {
513            return Err(EpError::KernelFailed(format!(
514                "{}: host download of {} bytes exceeds source {} bytes",
515                self.name(),
516                dst.len(),
517                src.len()
518            )));
519        }
520        if dst.is_empty() {
521            return Ok(());
522        }
523        // SAFETY: host accessibility is checked above, `dst` is uniquely
524        // borrowed, and `src` contains at least `dst.len()` readable bytes.
525        unsafe {
526            std::ptr::copy_nonoverlapping(src.as_ptr().cast(), dst.as_mut_ptr(), dst.len());
527        }
528        Ok(())
529    }
530
531    /// Block until all pending work on this EP completes.
532    fn sync(&self) -> Result<()>;
533
534    /// Export this EP as an ORT C ABI plugin, if supported (Phase 2).
535    fn as_ort_plugin(&self) -> Option<OrtPluginExport> {
536        None
537    }
538
539    /// EP-specific optimization passes, run after the generic optimizer.
540    fn custom_passes(&self) -> Vec<Box<dyn onnx_runtime_optimizer::OptimizationPass>> {
541        Vec::new()
542    }
543
544    /// Nodes this EP claims unconditionally (bypassing cost-model placement).
545    fn claim_nodes(&self, graph: &Graph) -> Vec<NodeId> {
546        let _ = graph;
547        Vec::new()
548    }
549
550    /// The `EPContext` node `source` key(s) this EP accepts for compiled-context
551    /// dispatch (`docs/ORT2.md` §55.6). The keys come from the EP's own
552    /// config/data — **never** hardcoded in loader/session dispatch. An empty
553    /// list (the default) means the EP does not participate in `EPContext`
554    /// (e.g. the pure-Rust CPU EP has no compile step).
555    fn context_source_keys(&self) -> Vec<String> {
556        Vec::new()
557    }
558
559    /// Produce the runtime [`EpContext`] for this EP's freshly compiled subgraph
560    /// (the §55.4 dump path calls this). Default: unsupported — an EP with no
561    /// compile step returns [`EpError::UnsupportedContext`].
562    fn save_context(&self) -> Result<EpContext> {
563        Err(EpError::UnsupportedContext {
564            ep: self.name().to_string(),
565        })
566    }
567
568    /// Restore this EP from a runtime [`EpContext`], skipping convert+compile
569    /// (the §55.3 load path calls this). Default: unsupported — an EP that does
570    /// not consume `EPContext` returns [`EpError::UnsupportedContext`].
571    fn load_context(&self, ctx: &EpContext) -> Result<()> {
572        let _ = ctx;
573        Err(EpError::UnsupportedContext {
574            ep: self.name().to_string(),
575        })
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    fn _assert_send_sync<T: Send + Sync>() {}
584
585    /// Leak a boxed byte slice as a stand-in host allocation.
586    fn host_alloc(size: usize, align: usize) -> DeviceBuffer {
587        let boxed = vec![0u8; size].into_boxed_slice();
588        let ptr = Box::into_raw(boxed) as *mut c_void;
589        // SAFETY: `ptr` is a valid, unique, non-null allocation of `size` bytes
590        // on the host, aligned to the allocator's guarantee (>= 1); we treat the
591        // CPU EP as its owner and free it exactly once in `host_free`.
592        unsafe { DeviceBuffer::from_raw_parts(ptr, DeviceId::cpu(), size, align) }
593    }
594
595    fn host_free(buf: DeviceBuffer) {
596        let size = buf.len();
597        let ptr = buf.into_raw() as *mut u8;
598        // SAFETY: reconstruct the exact `Box<[u8]>` leaked in `host_alloc` so it
599        // is freed once. `into_raw` consumed the handle, so no alias remains.
600        unsafe {
601            drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, size)));
602        }
603    }
604
605    #[test]
606    fn device_buffer_is_send_sync() {
607        _assert_send_sync::<DeviceBuffer>();
608    }
609
610    #[test]
611    fn buffer_metadata_and_single_free() {
612        let mut buf = host_alloc(128, 64);
613        assert_eq!(buf.len(), 128);
614        assert!(!buf.is_empty());
615        assert_eq!(buf.alignment(), 64);
616        assert_eq!(buf.device(), DeviceId::cpu());
617        assert!(!buf.as_ptr().is_null());
618        assert!(!buf.as_mut_ptr().is_null());
619        // Single free path — a double free here would trip ASan/Miri.
620        host_free(buf);
621    }
622
623    #[test]
624    fn buffer_moves_across_thread() {
625        let buf = host_alloc(64, 16);
626        let base = buf.as_ptr() as usize;
627        let handle = std::thread::spawn(move || {
628            assert_eq!(buf.len(), 64);
629            assert_eq!(buf.as_ptr() as usize, base);
630            buf // hand ownership back so the main thread frees it once
631        });
632        let buf = handle.join().unwrap();
633        host_free(buf);
634    }
635
636    #[test]
637    fn owned_buffer_is_not_borrowed() {
638        let buf = host_alloc(32, 16);
639        assert!(
640            !buf.is_borrowed(),
641            "from_raw_parts must produce an owned buffer"
642        );
643        host_free(buf);
644    }
645
646    /// A borrowed buffer aliases memory owned by someone else (here a `Vec`):
647    /// it reports `is_borrowed()`, exposes the aliased pointer, and consuming it
648    /// via `into_raw` must NOT free the backing — the `Vec` stays valid.
649    #[test]
650    fn borrowed_buffer_aliases_without_owning() {
651        let mut backing = vec![7u8; 64];
652        let ptr = backing.as_mut_ptr() as *mut c_void;
653        // SAFETY: `ptr`/`len` name `backing`'s live allocation (aligned to 1);
654        // `backing` outlives the buffer and every use below, and we never write
655        // through the borrowed handle.
656        let buf = unsafe { DeviceBuffer::from_borrowed_parts(ptr, DeviceId::cpu(), 64, 1) };
657        assert!(buf.is_borrowed());
658        assert_eq!(buf.len(), 64);
659        assert_eq!(buf.as_ptr(), ptr as *const c_void);
660        // Consume without freeing: `into_raw` must never free a borrowed buffer.
661        let raw = buf.into_raw();
662        assert_eq!(raw, ptr);
663        // `backing` is still fully valid — a free would be a use-after-free here.
664        assert!(backing.iter().all(|&b| b == 7));
665        backing[0] = 9;
666        assert_eq!(backing[0], 9);
667    }
668
669    #[test]
670    fn borrowed_mut_buffer_writes_without_owning() {
671        let mut backing = vec![0u8; 8];
672        let ptr = backing.as_mut_ptr() as *mut c_void;
673        // SAFETY: `backing` exclusively owns this writable region and outlives
674        // the temporary alias.
675        let mut buffer =
676            unsafe { DeviceBuffer::from_borrowed_mut_parts(ptr, DeviceId::cpu(), 8, 1) }
677                .expect("non-null backing");
678        assert!(buffer.is_borrowed());
679        // SAFETY: the alias has exclusive access to all eight backing bytes.
680        unsafe {
681            std::ptr::copy_nonoverlapping([1u8, 2, 3].as_ptr(), buffer.as_mut_ptr().cast(), 3);
682        }
683        assert_eq!(buffer.into_raw(), ptr);
684        assert_eq!(&backing[..3], &[1, 2, 3]);
685        assert!(
686            unsafe {
687                DeviceBuffer::from_borrowed_mut_parts(std::ptr::null_mut(), DeviceId::cpu(), 0, 1)
688            }
689            .is_none()
690        );
691    }
692}