Skip to main content

tensor_wasm_wasi_gpu/
device_mem.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! [`DeviceMemRegistry`] — instance-scoped store of explicit device-memory
5//! allocations.
6//!
7//! The `wasi:cuda` host surface historically relied on CUDA Unified Memory:
8//! a guest pointer argument was bounds-checked against linear memory and
9//! handed to `cuLaunchKernel` verbatim, the UVM driver migrating pages on
10//! demand. That limits portability to UVM-capable setups. The explicit
11//! device-buffer surface (`alloc` / `free` / `memcpy-h2d` / `memcpy-d2h`)
12//! lets a guest manage discrete device buffers that work on any CUDA host.
13//!
14//! This registry is the host-side bookkeeping for those buffers. It mirrors
15//! [`crate::registry::KernelRegistry`] exactly:
16//!
17//!   * every allocation is tagged with its owning [`InstanceId`], and
18//!     [`DeviceMemRegistry::lookup`] / [`DeviceMemRegistry::free`] refuse a
19//!     handle that belongs to a different instance (`AbiError::InvalidHandle`)
20//!     — a guest cannot forge another instance's handle;
21//!   * an aggregate-bytes cap ([`MAX_TOTAL_DEVICE_BYTES`]) is enforced with a
22//!     compare-and-swap loop so one instance cannot pin unbounded device
23//!     memory before tripping the per-instance count cap
24//!     ([`MAX_DEVICE_ALLOCS_PER_INSTANCE`]).
25//!
26//! ## Process-wide aggregate ceiling (M-3)
27//!
28//! The per-instance caps above are defence-in-depth but do not bound the
29//! *aggregate* across instances: each [`WasiCudaContext`] historically owns
30//! its own registry, so N instances could each admit up to
31//! [`MAX_TOTAL_DEVICE_BYTES`] with no global ceiling. Every registry therefore
32//! also charges a shared [`DeviceMemBudget`] — a process-wide counter of live
33//! device bytes and allocations — and refuses an `insert` that would push the
34//! shared total over [`MAX_PROCESS_DEVICE_BYTES`] (or the shared count over
35//! [`MAX_PROCESS_DEVICE_ALLOCS`]). By default every [`DeviceMemRegistry::new`]
36//! attaches to the same [`process_device_budget`] singleton, so the ceiling is
37//! genuinely process-wide regardless of how many contexts are created. This
38//! mirrors the intent of the shared [`crate::async_dispatch::BackPressure`]
39//! cap (one process-wide ceiling shared by every instance), but does not
40//! depend on the executor remembering to thread a clone: the singleton is the
41//! default. Tests that need an isolated budget use
42//! [`DeviceMemRegistry::with_budget`].
43//!
44//! [`WasiCudaContext`]: crate::host::WasiCudaContext
45//! On no-CUDA builds the registry records the *requested* size and a synthetic
46//! handle (no real `cuMemAlloc` runs); the host functions validate arguments
47//! and then return [`AbiError::NotAvailable`]. On CUDA builds the registry
48//! additionally carries the real device pointer (`CUdeviceptr`) so the
49//! `memcpy` paths can drive `cuMemcpyHtoD` / `cuMemcpyDtoH`.
50
51use std::sync::atomic::{AtomicU64, Ordering};
52use std::sync::{Arc, OnceLock};
53
54use dashmap::DashMap;
55use tensor_wasm_core::types::InstanceId;
56
57use crate::abi::AbiError;
58
59/// Maximum size of a single `alloc` request, in bytes (256 MiB).
60///
61/// A request above this cap is rejected with [`AbiError::QuotaExceeded`]
62/// before any driver call. Sized to comfortably hold a large tensor tile
63/// while bounding the damage a single hostile `alloc` can do.
64pub const MAX_DEVICE_ALLOC_BYTES: u64 = 256 * 1024 * 1024;
65
66/// Maximum number of live device allocations a single instance may hold.
67pub const MAX_DEVICE_ALLOCS_PER_INSTANCE: usize = 4096;
68
69/// Soft cap on aggregate retained device bytes per instance (sum of live
70/// allocation sizes). Set to 16x the per-call cap (4 GiB) so a single
71/// instance cannot pin unbounded device memory below the per-call ceiling
72/// before tripping [`MAX_DEVICE_ALLOCS_PER_INSTANCE`].
73pub const MAX_TOTAL_DEVICE_BYTES: u64 = 16 * MAX_DEVICE_ALLOC_BYTES;
74
75/// Process-wide ceiling on aggregate live device bytes across *every*
76/// instance's registry (M-3).
77///
78/// The per-instance [`MAX_TOTAL_DEVICE_BYTES`] cap bounds one registry, but
79/// without a shared ceiling N instances could each pin that much, so the true
80/// process exposure scales with the instance count. This constant is the
81/// shared upper bound that the [`DeviceMemBudget`] enforces in addition to the
82/// per-instance caps.
83///
84/// Sized at 4x the per-instance aggregate cap (64 GiB): generous enough that a
85/// handful of well-behaved instances are never throttled by it, while still
86/// putting a finite ceiling on total pinned device memory so a fan-out of
87/// instances cannot collectively exhaust the device. The multiple (rather than
88/// equality with the per-instance cap) keeps the per-instance cap meaningful
89/// as the *first* line of defence — a single runaway instance trips its own
90/// cap long before it can monopolise the process budget. Hosts with smaller
91/// GPUs should treat this as an upper bound and rely on the per-instance cap
92/// plus the real `cuMemAlloc` failure for tighter physical limits.
93pub const MAX_PROCESS_DEVICE_BYTES: u64 = 4 * MAX_TOTAL_DEVICE_BYTES;
94
95/// Process-wide ceiling on the number of live device allocations across every
96/// instance's registry (M-3).
97///
98/// Mirrors [`MAX_PROCESS_DEVICE_BYTES`] for the count axis: 4x the per-instance
99/// count cap, so the per-instance count cap remains the first tripwire while a
100/// process-wide ceiling still bounds total live handles (each of which costs a
101/// driver allocation and registry slot).
102pub const MAX_PROCESS_DEVICE_ALLOCS: usize = 4 * MAX_DEVICE_ALLOCS_PER_INSTANCE;
103
104/// Process-wide aggregate device-memory budget shared by every
105/// [`DeviceMemRegistry`].
106///
107/// Tracks the sum of live allocation sizes and the count of live allocations
108/// across all instances. A registry charges this budget on `insert` (refusing
109/// the allocation with [`AbiError::QuotaExceeded`] when it would push the
110/// shared total over [`MAX_PROCESS_DEVICE_BYTES`] / [`MAX_PROCESS_DEVICE_ALLOCS`])
111/// and credits it back on `free` / drop. All arithmetic is checked /
112/// saturating so a bookkeeping slip can never wrap the counters.
113///
114/// This is the device-memory analogue of the shared
115/// [`crate::async_dispatch::BackPressure`] cap: one process-wide ceiling shared
116/// by every instance. Unlike `BackPressure`, the default registry constructor
117/// attaches to the [`process_device_budget`] singleton automatically, so the
118/// ceiling holds even if an embedder constructs contexts without explicitly
119/// threading shared state.
120#[derive(Debug, Default)]
121pub struct DeviceMemBudget {
122    /// Sum of live allocation sizes across all attached registries.
123    total_bytes: AtomicU64,
124    /// Count of live allocations across all attached registries.
125    total_allocs: AtomicU64,
126}
127
128impl DeviceMemBudget {
129    /// Construct an empty budget. Most callers want the shared
130    /// [`process_device_budget`] singleton instead; this is for tests that
131    /// need an isolated budget.
132    pub fn new() -> Self {
133        Self {
134            total_bytes: AtomicU64::new(0),
135            total_allocs: AtomicU64::new(0),
136        }
137    }
138
139    /// Reserve `size` bytes (one allocation) against the process-wide budget.
140    ///
141    /// Uses a compare-and-swap loop on the byte counter so the check + add is
142    /// atomic against concurrent allocations from other instances. On success
143    /// the count is bumped and `Ok(())` returned; if the reservation would
144    /// breach either process cap, nothing is mutated and
145    /// [`AbiError::QuotaExceeded`] is returned.
146    fn try_reserve(&self, size: u64) -> Result<(), AbiError> {
147        // Count axis first: a single fetch_add we roll back if it overshoots,
148        // mirroring the byte CAS below. Doing the count check up front keeps
149        // the (rarer) byte CAS from spinning when the count cap is the gate.
150        let prev_allocs = self.total_allocs.fetch_add(1, Ordering::AcqRel);
151        if prev_allocs >= MAX_PROCESS_DEVICE_ALLOCS as u64 {
152            // Roll back the speculative bump and refuse.
153            self.total_allocs.fetch_sub(1, Ordering::AcqRel);
154            return Err(AbiError::QuotaExceeded);
155        }
156        let mut current = self.total_bytes.load(Ordering::Acquire);
157        loop {
158            let next = current.saturating_add(size);
159            if next > MAX_PROCESS_DEVICE_BYTES {
160                // Byte cap tripped — release the count reservation we took.
161                self.total_allocs.fetch_sub(1, Ordering::AcqRel);
162                return Err(AbiError::QuotaExceeded);
163            }
164            match self.total_bytes.compare_exchange_weak(
165                current,
166                next,
167                Ordering::AcqRel,
168                Ordering::Acquire,
169            ) {
170                Ok(_) => return Ok(()),
171                Err(observed) => current = observed,
172            }
173        }
174    }
175
176    /// Credit `size` bytes (one allocation) back to the budget on free / drop.
177    /// Saturating so a double-credit can never wrap below zero.
178    fn release(&self, size: u64) {
179        let _ = self
180            .total_bytes
181            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |cur| {
182                Some(cur.saturating_sub(size))
183            });
184        let _ = self
185            .total_allocs
186            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |cur| {
187                Some(cur.saturating_sub(1))
188            });
189    }
190
191    /// Aggregate live device bytes across every attached registry. Visible for
192    /// metrics and tests.
193    pub fn total_bytes(&self) -> u64 {
194        self.total_bytes.load(Ordering::Acquire)
195    }
196
197    /// Aggregate live allocation count across every attached registry. Visible
198    /// for metrics and tests.
199    pub fn total_allocs(&self) -> u64 {
200        self.total_allocs.load(Ordering::Acquire)
201    }
202}
203
204/// The process-wide [`DeviceMemBudget`] singleton.
205///
206/// Every [`DeviceMemRegistry::new`] attaches to this, so the
207/// [`MAX_PROCESS_DEVICE_BYTES`] / [`MAX_PROCESS_DEVICE_ALLOCS`] ceiling is
208/// enforced across all instances in the process without the embedder having to
209/// thread shared state explicitly. Tests that need isolation construct their
210/// own budget via [`DeviceMemRegistry::with_budget`].
211pub fn process_device_budget() -> &'static Arc<DeviceMemBudget> {
212    static BUDGET: OnceLock<Arc<DeviceMemBudget>> = OnceLock::new();
213    BUDGET.get_or_init(|| Arc::new(DeviceMemBudget::new()))
214}
215
216/// Metadata about a single device-memory allocation.
217#[derive(Debug)]
218pub struct DeviceMemEntry {
219    /// Owning instance (used to authorise `free` / `memcpy` calls).
220    pub owner: InstanceId,
221    /// Requested allocation size in bytes.
222    pub size: u64,
223    /// CUDA device pointer; only meaningful when the `cuda` feature is
224    /// enabled. On no-CUDA builds the field is absent so the registry can
225    /// still be exercised by tests.
226    #[cfg(feature = "cuda")]
227    pub device_ptr: cust::sys::CUdeviceptr,
228}
229
230/// Cheap handle to a device-memory entry's stable fields.
231///
232/// Returned by [`DeviceMemRegistry::lookup`]. Carries the device pointer on
233/// CUDA builds so the `memcpy` paths can drive the driver without holding the
234/// `dashmap` entry borrow across the copy.
235#[derive(Clone, Debug)]
236pub struct DeviceMemHandle {
237    /// Owning instance.
238    pub owner: InstanceId,
239    /// Allocation size in bytes.
240    pub size: u64,
241    /// Device pointer on CUDA builds.
242    #[cfg(feature = "cuda")]
243    pub device_ptr: cust::sys::CUdeviceptr,
244}
245
246/// Instance-scoped device-memory registry.
247pub struct DeviceMemRegistry {
248    next_handle: AtomicU64,
249    entries: DashMap<u64, DeviceMemEntry>,
250    /// Sum of live allocation sizes. Tracked separately so `insert` can
251    /// reject above [`MAX_TOTAL_DEVICE_BYTES`] without scanning the map.
252    total_device_bytes: AtomicU64,
253    /// Process-wide aggregate budget this registry charges in addition to its
254    /// own per-instance caps (M-3). Shared by every registry created via
255    /// [`Self::new`] (the [`process_device_budget`] singleton); tests may
256    /// supply an isolated budget via [`Self::with_budget`].
257    budget: Arc<DeviceMemBudget>,
258}
259
260impl Default for DeviceMemRegistry {
261    fn default() -> Self {
262        Self::new()
263    }
264}
265
266impl DeviceMemRegistry {
267    /// Construct an empty registry.
268    ///
269    /// Handles start at 1 so callers may reserve 0 as a sentinel; they are
270    /// otherwise sequential. Cross-instance forgery is prevented by the
271    /// owner-`InstanceId` check in [`Self::lookup`] / [`Self::free`], so the
272    /// handle space does not need the randomised-seed treatment the kernel
273    /// registry uses for its ids.
274    pub fn new() -> Self {
275        Self::with_budget(Arc::clone(process_device_budget()))
276    }
277
278    /// Construct an empty registry charging the given process-wide
279    /// [`DeviceMemBudget`] instead of the shared [`process_device_budget`]
280    /// singleton.
281    ///
282    /// Intended for tests that need an isolated aggregate so the process-wide
283    /// caps can be exercised without the global singleton bleeding state
284    /// across test cases (which run in the same process). Production code
285    /// should use [`Self::new`] so every instance shares the one ceiling.
286    pub fn with_budget(budget: Arc<DeviceMemBudget>) -> Self {
287        Self {
288            next_handle: AtomicU64::new(1),
289            entries: DashMap::new(),
290            total_device_bytes: AtomicU64::new(0),
291            budget,
292        }
293    }
294
295    /// Reserve aggregate-bytes budget for a new allocation, returning the
296    /// freshly-assigned handle on success.
297    ///
298    /// Enforces [`MAX_DEVICE_ALLOCS_PER_INSTANCE`] and
299    /// [`MAX_TOTAL_DEVICE_BYTES`] (the per-instance caps) **and** the
300    /// process-wide [`MAX_PROCESS_DEVICE_BYTES`] / [`MAX_PROCESS_DEVICE_ALLOCS`]
301    /// caps via the shared [`DeviceMemBudget`] (all returning
302    /// [`AbiError::QuotaExceeded`]) before inserting the entry. The per-call
303    /// [`MAX_DEVICE_ALLOC_BYTES`] cap is the caller's responsibility (the host
304    /// function checks it before any driver call) — this method only enforces
305    /// the aggregate caps so the check + add stays atomic against concurrent
306    /// allocations.
307    ///
308    /// The per-instance caps are checked first (defence in depth: a single
309    /// runaway instance trips its own cap before it can charge the shared
310    /// budget), then the process-wide budget. If the process budget refuses,
311    /// the per-instance byte reservation is rolled back so the two counters
312    /// never drift.
313    pub fn insert(&self, entry: DeviceMemEntry) -> Result<u64, AbiError> {
314        if self.entries.len() >= MAX_DEVICE_ALLOCS_PER_INSTANCE {
315            return Err(AbiError::QuotaExceeded);
316        }
317        let add = entry.size;
318        // Compare-and-swap loop so the check + add is atomic against
319        // concurrent allocations — mirrors `KernelRegistry::register`.
320        let mut current = self.total_device_bytes.load(Ordering::Acquire);
321        loop {
322            let next = current.saturating_add(add);
323            if next > MAX_TOTAL_DEVICE_BYTES {
324                return Err(AbiError::QuotaExceeded);
325            }
326            match self.total_device_bytes.compare_exchange_weak(
327                current,
328                next,
329                Ordering::AcqRel,
330                Ordering::Acquire,
331            ) {
332                Ok(_) => break,
333                Err(observed) => current = observed,
334            }
335        }
336        // Charge the process-wide budget. On refusal, release the
337        // per-instance reservation we just took so the counters stay in sync.
338        if let Err(e) = self.budget.try_reserve(add) {
339            let _ =
340                self.total_device_bytes
341                    .fetch_update(Ordering::AcqRel, Ordering::Acquire, |cur| {
342                        Some(cur.saturating_sub(add))
343                    });
344            return Err(e);
345        }
346        let handle = self.next_handle.fetch_add(1, Ordering::Relaxed);
347        self.entries.insert(handle, entry);
348        Ok(handle)
349    }
350
351    /// Look up an allocation by handle, returning an independent handle copy.
352    ///
353    /// Returns `Err(AbiError::InvalidHandle)` if the handle is unknown or
354    /// belongs to a different instance.
355    pub fn lookup(&self, handle: u64, owner: InstanceId) -> Result<DeviceMemHandle, AbiError> {
356        let r = self.entries.get(&handle).ok_or(AbiError::InvalidHandle)?;
357        if r.owner != owner {
358            return Err(AbiError::InvalidHandle);
359        }
360        Ok(DeviceMemHandle {
361            owner: r.owner,
362            size: r.size,
363            #[cfg(feature = "cuda")]
364            device_ptr: r.device_ptr,
365        })
366    }
367
368    /// Remove an allocation owned by `owner`, returning its entry.
369    ///
370    /// Returns `Err(AbiError::InvalidHandle)` when the handle is unknown or
371    /// belongs to another instance — a guest cannot free a buffer it does not
372    /// own. On success both the per-instance aggregate-bytes counter and the
373    /// shared process-wide [`DeviceMemBudget`] are credited back.
374    pub fn free(&self, handle: u64, owner: InstanceId) -> Result<DeviceMemEntry, AbiError> {
375        // Authorise the owner before removing so a cross-owner `free` cannot
376        // even observe whether the handle exists (it always sees
377        // `InvalidHandle`, matching the `lookup` discrimination).
378        {
379            let r = self.entries.get(&handle).ok_or(AbiError::InvalidHandle)?;
380            if r.owner != owner {
381                return Err(AbiError::InvalidHandle);
382            }
383        }
384        let (_, entry) = self
385            .entries
386            .remove(&handle)
387            .ok_or(AbiError::InvalidHandle)?;
388        let _ = self
389            .total_device_bytes
390            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |cur| {
391                Some(cur.saturating_sub(entry.size))
392            });
393        self.budget.release(entry.size);
394        Ok(entry)
395    }
396
397    /// Number of currently-live allocations.
398    pub fn len(&self) -> usize {
399        self.entries.len()
400    }
401
402    /// True if there are no live allocations.
403    pub fn is_empty(&self) -> bool {
404        self.entries.is_empty()
405    }
406
407    /// Aggregate device bytes currently retained. Visible for metrics and
408    /// tests.
409    pub fn total_device_bytes(&self) -> u64 {
410        self.total_device_bytes.load(Ordering::Acquire)
411    }
412
413    /// Borrow the process-wide [`DeviceMemBudget`] this registry charges.
414    /// Visible for metrics and tests.
415    pub fn budget(&self) -> &Arc<DeviceMemBudget> {
416        &self.budget
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    fn entry(owner: InstanceId, size: u64) -> DeviceMemEntry {
425        DeviceMemEntry {
426            owner,
427            size,
428            #[cfg(feature = "cuda")]
429            device_ptr: 0,
430        }
431    }
432
433    #[test]
434    fn insert_then_lookup() {
435        let reg = DeviceMemRegistry::new();
436        let h = reg.insert(entry(InstanceId(1), 4096)).unwrap();
437        let found = reg.lookup(h, InstanceId(1)).unwrap();
438        assert_eq!(found.owner, InstanceId(1));
439        assert_eq!(found.size, 4096);
440    }
441
442    #[test]
443    fn lookup_wrong_owner_rejected() {
444        let reg = DeviceMemRegistry::new();
445        let h = reg.insert(entry(InstanceId(1), 4096)).unwrap();
446        assert_eq!(
447            reg.lookup(h, InstanceId(2)).unwrap_err(),
448            AbiError::InvalidHandle
449        );
450    }
451
452    #[test]
453    fn free_wrong_owner_rejected() {
454        let reg = DeviceMemRegistry::new();
455        let h = reg.insert(entry(InstanceId(1), 4096)).unwrap();
456        assert_eq!(
457            reg.free(h, InstanceId(2)).unwrap_err(),
458            AbiError::InvalidHandle
459        );
460        // The entry is still present and still owned by instance 1.
461        assert!(reg.lookup(h, InstanceId(1)).is_ok());
462    }
463
464    #[test]
465    fn free_unknown_rejected() {
466        let reg = DeviceMemRegistry::new();
467        assert_eq!(
468            reg.free(999, InstanceId(1)).unwrap_err(),
469            AbiError::InvalidHandle
470        );
471    }
472
473    #[test]
474    fn alloc_free_lifecycle_tracks_bytes() {
475        let reg = DeviceMemRegistry::new();
476        assert!(reg.is_empty());
477        let h = reg.insert(entry(InstanceId(1), 8192)).unwrap();
478        assert_eq!(reg.len(), 1);
479        assert_eq!(reg.total_device_bytes(), 8192);
480        let freed = reg.free(h, InstanceId(1)).unwrap();
481        assert_eq!(freed.size, 8192);
482        assert!(reg.is_empty());
483        assert_eq!(reg.total_device_bytes(), 0);
484        // The handle is gone now; a second free fails.
485        assert_eq!(
486            reg.free(h, InstanceId(1)).unwrap_err(),
487            AbiError::InvalidHandle
488        );
489    }
490
491    /// A registry charging an isolated [`DeviceMemBudget`] so process-wide
492    /// counters cannot bleed across tests (all unit tests share one process,
493    /// hence one [`process_device_budget`] singleton).
494    fn iso_reg() -> DeviceMemRegistry {
495        DeviceMemRegistry::with_budget(Arc::new(DeviceMemBudget::new()))
496    }
497
498    #[test]
499    fn aggregate_byte_cap_enforced() {
500        let reg = iso_reg();
501        let per = MAX_DEVICE_ALLOC_BYTES; // 256 MiB
502        let cap_count = (MAX_TOTAL_DEVICE_BYTES / per) as usize; // 16
503        for _ in 0..cap_count {
504            reg.insert(entry(InstanceId(1), per)).expect("under cap");
505        }
506        // The next allocation at the per-call max trips the aggregate cap.
507        assert_eq!(
508            reg.insert(entry(InstanceId(1), per)).unwrap_err(),
509            AbiError::QuotaExceeded
510        );
511    }
512
513    #[test]
514    fn per_instance_count_cap_enforced() {
515        let reg = iso_reg();
516        // Tiny allocations so the count cap (not the byte cap) gates.
517        for _ in 0..MAX_DEVICE_ALLOCS_PER_INSTANCE {
518            reg.insert(entry(InstanceId(1), 1))
519                .expect("under count cap");
520        }
521        assert_eq!(
522            reg.insert(entry(InstanceId(1), 1)).unwrap_err(),
523            AbiError::QuotaExceeded
524        );
525    }
526
527    /// Two registries sharing one [`DeviceMemBudget`] (the production setup,
528    /// where every instance's registry charges the same process-wide budget)
529    /// cannot collectively exceed [`MAX_PROCESS_DEVICE_BYTES`], even though
530    /// neither has tripped its own per-instance [`MAX_TOTAL_DEVICE_BYTES`] cap.
531    #[test]
532    fn process_byte_cap_enforced_across_registries() {
533        let budget = Arc::new(DeviceMemBudget::new());
534        let per = MAX_DEVICE_ALLOC_BYTES; // 256 MiB
535                                          // Per-instance aggregate cap holds this many per-call-max allocations.
536        let per_reg = (MAX_TOTAL_DEVICE_BYTES / per) as usize; // 16
537                                                               // Process cap is 4x the per-instance cap, so it takes 4 saturated
538                                                               // registries to reach it.
539        let regs: Vec<DeviceMemRegistry> = (0..4)
540            .map(|_| DeviceMemRegistry::with_budget(Arc::clone(&budget)))
541            .collect();
542        for (i, reg) in regs.iter().enumerate() {
543            for _ in 0..per_reg {
544                reg.insert(entry(InstanceId(i as u128 + 1), per))
545                    .expect("each registry stays under its own per-instance cap");
546            }
547        }
548        // Every registry is at its per-instance cap and the shared budget is
549        // at the process ceiling. A fresh registry — well under its own
550        // per-instance cap — is refused on the process budget.
551        assert_eq!(budget.total_bytes(), MAX_PROCESS_DEVICE_BYTES);
552        let extra = DeviceMemRegistry::with_budget(Arc::clone(&budget));
553        assert_eq!(
554            extra.insert(entry(InstanceId(99), per)).unwrap_err(),
555            AbiError::QuotaExceeded
556        );
557        // Freeing one allocation credits the shared budget back, re-admitting
558        // exactly one more allocation.
559        let freed_handle = 1u64; // first handle in regs[0]
560        regs[0]
561            .free(freed_handle, InstanceId(1))
562            .expect("free a live allocation");
563        assert_eq!(budget.total_bytes(), MAX_PROCESS_DEVICE_BYTES - per);
564        let h = extra
565            .insert(entry(InstanceId(99), per))
566            .expect("re-admitted after a free freed shared headroom");
567        assert!(extra.lookup(h, InstanceId(99)).is_ok());
568    }
569
570    /// The process-wide *count* cap ([`MAX_PROCESS_DEVICE_ALLOCS`]) is enforced
571    /// across registries sharing one budget, independent of the byte cap.
572    #[test]
573    fn process_alloc_count_cap_enforced_across_registries() {
574        let budget = Arc::new(DeviceMemBudget::new());
575        // Tiny 1-byte allocations so the count cap (not the byte cap) gates.
576        // Spread across enough registries that no single one trips its own
577        // per-instance count cap first.
578        let regs: Vec<DeviceMemRegistry> = (0..4)
579            .map(|_| DeviceMemRegistry::with_budget(Arc::clone(&budget)))
580            .collect();
581        for reg in &regs {
582            for _ in 0..MAX_DEVICE_ALLOCS_PER_INSTANCE {
583                reg.insert(entry(InstanceId(1), 1))
584                    .expect("under count cap");
585            }
586        }
587        assert_eq!(budget.total_allocs(), MAX_PROCESS_DEVICE_ALLOCS as u64);
588        let extra = DeviceMemRegistry::with_budget(Arc::clone(&budget));
589        assert_eq!(
590            extra.insert(entry(InstanceId(1), 1)).unwrap_err(),
591            AbiError::QuotaExceeded
592        );
593    }
594
595    /// A `free` credits the shared budget back so the process counters track
596    /// *live* bytes / allocations, not cumulative.
597    #[test]
598    fn free_credits_shared_budget() {
599        let budget = Arc::new(DeviceMemBudget::new());
600        let reg = DeviceMemRegistry::with_budget(Arc::clone(&budget));
601        let h = reg.insert(entry(InstanceId(1), 8192)).unwrap();
602        assert_eq!(budget.total_bytes(), 8192);
603        assert_eq!(budget.total_allocs(), 1);
604        reg.free(h, InstanceId(1)).unwrap();
605        assert_eq!(budget.total_bytes(), 0);
606        assert_eq!(budget.total_allocs(), 0);
607    }
608
609    #[test]
610    fn handles_are_unique_and_increasing() {
611        let reg = DeviceMemRegistry::new();
612        let a = reg.insert(entry(InstanceId(1), 1)).unwrap();
613        let b = reg.insert(entry(InstanceId(1), 1)).unwrap();
614        assert_ne!(a, b);
615        assert_eq!(a + 1, b);
616    }
617}