Skip to main content

tensor_wasm_mem/
pool.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! `UnifiedMemoryPool` — a bump allocator that amortises the cost of CUDA
5//! Unified Memory allocations.
6//!
7//! `cudaMallocManaged` is expensive (tens to hundreds of microseconds per call
8//! on busy systems). For Wasm linear memory we want sub-microsecond allocations
9//! at instance-spawn time. The pool pre-allocates one large [`UnifiedBuffer`]
10//! and hands out aligned sub-slices via a simple bump pointer.
11
12use std::fmt;
13use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
14
15use crate::unified::{DeviceId, UnifiedBuffer, UnifiedError};
16
17/// A bump-allocated pool carved from a single underlying [`UnifiedBuffer`].
18///
19/// Allocations succeed until the slab is exhausted; the pool does not reclaim
20/// freed regions (callers maintain their own life cycle by dropping
21/// [`PoolAllocation`]s, which mark the bump pointer as logically released only
22/// in the *all-released-at-end* discipline used by ephemeral Wasm instances).
23///
24/// # Pool teardown contract
25///
26/// Slabs handed out as Wasm linear memory (via
27/// [`crate::wasm_memory::TensorWasmMemoryCreator`]) preserve the
28/// *monotonic bump* invariant for the lifetime of the pool: the
29/// `PoolAllocation` drop guard returned by [`Self::allocate`] is intentionally
30/// `std::mem::forget`-ed by the linear-memory creator, so the bump pointer
31/// never rewinds while pooled instances are alive. The bump pointer only
32/// rewinds via an explicit [`Self::reset`] call, which requires `&mut self`
33/// (every other `Arc<UnifiedMemoryPool>` clone dropped) AND `live == 0`
34/// (every issued `PoolAllocation` AND every pool-backed Wasm linear memory
35/// dropped).
36///
37/// As of audit T6, `crate::wasm_memory::PooledLinearMemory`'s own `Drop`
38/// mirrors the `mem::forget(alloc)` effect by calling `Self::release`
39/// from its destructor: the bump pointer is *not* rewound (so any
40/// `base_ptr` held by other pool consumers stays valid), but the live
41/// counter is decremented so a future `reset()` can run once every issued
42/// memory has been dropped. Before T6 the counter stayed elevated forever
43/// once any pool-backed Wasm memory was issued, permanently blocking
44/// `reset()`; that left operators with no path to recycle a slab across
45/// tenants short of dropping the pool itself.
46///
47/// Operators that want per-tenant resets must still satisfy both gates
48/// (drop every `Arc<UnifiedMemoryPool>` clone except their own, and drop
49/// every issued memory) — alternately they can continue to use one pool
50/// per tenant and drop the pool wholesale. The pinned behaviour lives in
51/// `crates/tensor-wasm-mem/tests/pool_teardown_contract.rs` and the
52/// call-site rationale is recorded inline above the `std::mem::forget(alloc)`
53/// line in `TensorWasmMemoryCreator::new_memory`.
54pub struct UnifiedMemoryPool {
55    slab: UnifiedBuffer,
56    /// Next free byte offset within the slab.
57    ///
58    /// Audit T26: this counter was previously a field on a `Mutex<PoolState>`
59    /// shared with `live` and `issued_total`. Every `allocate` call therefore
60    /// took the same mutex, serialising tenants that were otherwise touching
61    /// disjoint byte ranges. Moving the bump to an `AtomicUsize` with a CAS
62    /// loop in [`Self::allocate`] removes that contention point while
63    /// preserving the disjoint-allocation invariant (a successful CAS proves
64    /// `[old_bump, new_bump)` was reserved exclusively by this caller).
65    ///
66    /// The CAS must NOT race with [`Self::reset`], which would let `allocate`
67    /// hand out a region overlapping a tenant's still-live `base_ptr`. The
68    /// `&mut self` signature on `reset` (T4) provides exactly that mutual
69    /// exclusion: while reset holds `&mut self`, the type system forbids any
70    /// concurrent `&self` call into `allocate`. Inside reset we therefore use
71    /// `*self.bump.get_mut() = 0` (a non-atomic write through the unique
72    /// reference) rather than `store(Release)`, because there is provably no
73    /// other thread observing the atomic at that point.
74    bump: AtomicUsize,
75    /// Outstanding allocations counter; the slab is "reset-eligible" when
76    /// this hits zero. Incremented atomically in [`Self::allocate`] AFTER
77    /// the bump reservation succeeds, decremented in [`Self::release`] (the
78    /// safe `PoolAllocation::Drop` path and the T6 `PooledLinearMemory::Drop`
79    /// mirror path).
80    live: AtomicUsize,
81    /// Total bytes ever issued (sticky counter for metrics). Saturates at
82    /// `u64::MAX`. Never decremented; survives [`Self::reset`] intact.
83    issued_total: AtomicU64,
84}
85
86/// A region of memory carved from a pool. Drops decrement the pool's live count.
87pub struct PoolAllocation<'p> {
88    pool: &'p UnifiedMemoryPool,
89    offset: usize,
90    size: usize,
91}
92
93impl UnifiedMemoryPool {
94    /// Create a pool that owns `capacity` bytes on the default device.
95    pub fn new(capacity: usize) -> Result<Self, UnifiedError> {
96        Self::new_on(capacity, DeviceId::default())
97    }
98
99    /// Create a pool that owns `capacity` bytes on the named device.
100    pub fn new_on(capacity: usize, device_id: DeviceId) -> Result<Self, UnifiedError> {
101        let slab = UnifiedBuffer::new_on(capacity, device_id)?;
102        Ok(Self {
103            slab,
104            bump: AtomicUsize::new(0),
105            live: AtomicUsize::new(0),
106            issued_total: AtomicU64::new(0),
107        })
108    }
109
110    /// Slab capacity in bytes.
111    pub fn capacity(&self) -> usize {
112        self.slab.len()
113    }
114
115    /// Device that the underlying slab is anchored to.
116    pub fn device_id(&self) -> DeviceId {
117        self.slab.device_id()
118    }
119
120    /// Bytes still available for new allocations.
121    pub fn remaining(&self) -> usize {
122        // `Acquire` pairs with the `Release` CAS in `allocate`: any caller
123        // reading `remaining` after observing a successful `allocate` on
124        // another thread sees the bumped value.
125        self.slab
126            .len()
127            .saturating_sub(self.bump.load(Ordering::Acquire))
128    }
129
130    /// Outstanding allocation count.
131    pub fn live_allocations(&self) -> usize {
132        // `Acquire` so a thread that observes `live == 0` and goes on to
133        // `reset()` is guaranteed to have happens-before with every prior
134        // `release` increment-then-decrement.
135        self.live.load(Ordering::Acquire)
136    }
137
138    /// Total bytes issued since the pool was created (or last reset).
139    pub fn issued_total(&self) -> u64 {
140        // Metrics path; `Relaxed` is sufficient — operators only read this for
141        // alerting, not to reason about happens-before with allocation.
142        self.issued_total.load(Ordering::Relaxed)
143    }
144
145    /// Allocate `size` bytes aligned to `align` (must be a power of two).
146    ///
147    /// Returns `Err(UnifiedError::TooLarge { requested, limit })` when the
148    /// slab is exhausted (carrying the requested size and the pool capacity
149    /// so downstream layers can plumb the figures into
150    /// `TensorWasmError::MemoryExhausted` without parsing strings), or
151    /// `Err(UnifiedError::Allocation(...))` when `align` is zero / not a
152    /// power of two / exceeds the maximum alignment cap.
153    pub fn allocate(&self, size: usize, align: usize) -> Result<PoolAllocation<'_>, UnifiedError> {
154        if size == 0 {
155            return Err(UnifiedError::ZeroSize);
156        }
157        if align == 0 || !align.is_power_of_two() {
158            return Err(UnifiedError::Allocation(format!(
159                "alignment {align} is not a non-zero power of two"
160            )));
161        }
162        // Cap alignment at 1 GiB. Larger values would dwarf any realistic slab
163        // size and only existed in the API surface to satisfy `is_power_of_two`
164        // (which accepts `1 << 63`). Rejecting them early prevents
165        // `(bump + align - 1)` from silently wrapping into a tiny positive
166        // value that then bypasses the exhaustion check below.
167        const MAX_ALIGN: usize = 1 << 30;
168        if align > MAX_ALIGN {
169            return Err(UnifiedError::Allocation(format!(
170                "alignment {align} exceeds maximum {MAX_ALIGN}"
171            )));
172        }
173
174        // Audit T26: CAS loop on the bump pointer. Previously this critical
175        // section took a `parking_lot::Mutex<PoolState>` to update `bump`,
176        // `live`, and `issued_total` atomically. The mutex serialised
177        // unrelated tenants on the hot allocation path; the atomic-bump
178        // version lets disjoint allocations proceed in parallel without
179        // changing any externally visible behaviour.
180        //
181        // Disjoint-region invariant: a CAS success on `bump` proves nothing
182        // else updated `bump` between our `load` and `compare_exchange_weak`.
183        // Therefore the byte range `[aligned_bump, end)` was unreserved at
184        // CAS time and is now reserved exclusively by this caller. The zero-
185        // fill below targets that range and cannot race another allocator
186        // because the bump pointer has already moved past `end`.
187        //
188        // Reset interaction: `reset` requires `&mut self` (T4), so no
189        // concurrent `&self` allocate call can be live during reset, which
190        // means a reset will never race with this CAS.
191        let (aligned_bump, _end) = loop {
192            // `Relaxed` is sufficient here: the only ordering we care about
193            // is on the CAS itself, where `AcqRel` ties the bump update to
194            // the subsequent zero-fill (`Acquire`) and to any other thread
195            // that later observes the new bump (`Release`).
196            let current_bump = self.bump.load(Ordering::Relaxed);
197            // Compute `(bump + align - 1) & !(align - 1)` with overflow
198            // checks so a pathological `bump` near `usize::MAX` cannot wrap.
199            let aligned = current_bump
200                .checked_add(align - 1)
201                .map(|v| v & !(align - 1))
202                .ok_or_else(|| UnifiedError::Allocation("alignment overflow".into()))?;
203            let new_end = aligned
204                .checked_add(size)
205                .ok_or_else(|| UnifiedError::Allocation("offset overflow".into()))?;
206            if new_end > self.slab.len() {
207                // Pool exhaustion is reported as the structured `TooLarge`
208                // variant so the `From<UnifiedError> for TensorWasmError`
209                // impl can route exhaustion to
210                // `MemoryExhausted { requested, limit }` with the real
211                // figures, not zeroed placeholders. The `requested` field
212                // reflects the caller-visible size; `limit` is the slab
213                // capacity so operators can size pools from telemetry.
214                return Err(UnifiedError::TooLarge {
215                    requested: size as u64,
216                    limit: self.slab.len() as u64,
217                });
218            }
219            // `compare_exchange_weak` is fine on this hot path: spurious
220            // failures simply re-iterate the loop. `AcqRel` on success
221            // synchronises with the zero-fill below and with later
222            // observers; `Relaxed` on failure is sufficient because we
223            // re-read `bump` at the top of the next iteration.
224            match self.bump.compare_exchange_weak(
225                current_bump,
226                new_end,
227                Ordering::AcqRel,
228                Ordering::Relaxed,
229            ) {
230                Ok(_) => break (aligned, new_end),
231                Err(_) => continue,
232            }
233        };
234        // The CAS succeeded; we now own `[aligned_bump, end)`.
235        // `live` and `issued_total` are independent atomic counters — no
236        // ordering dependency on each other. `Release` on `live` so a
237        // reader that later observes `live > 0` also observes the bump
238        // update via the same fence chain.
239        self.live.fetch_add(1, Ordering::Release);
240        // `Relaxed` for the sticky metrics counter — operators read this
241        // for alerting, not synchronisation. `saturating_add` is performed
242        // by a CAS loop to preserve the saturating-at-u64::MAX behaviour
243        // of the original `st.issued_total.saturating_add(...)` call.
244        let size_u64 = size as u64;
245        let mut issued = self.issued_total.load(Ordering::Relaxed);
246        loop {
247            let next = issued.saturating_add(size_u64);
248            match self.issued_total.compare_exchange_weak(
249                issued,
250                next,
251                Ordering::Relaxed,
252                Ordering::Relaxed,
253            ) {
254                Ok(_) => break,
255                Err(observed) => issued = observed,
256            }
257        }
258        // Cross-tenant data-leak mitigation (audit H1):
259        // -------------------------------------------------------------------
260        // The slab is recycled across tenants via `reset()`, which only resets
261        // the bump pointer — recycled bytes still carry the previous tenant's
262        // data. Zero the freshly-carved region before we hand the
263        // `PoolAllocation` to the caller so a guest cannot observe a peer's
264        // memory through an uninitialised read.
265        //
266        // We use `ptr::write_bytes` rather than `slice::fill(0)` for two
267        // reasons: (1) it lowers to a single `memset` intrinsic on every
268        // backend LLVM cares about, where `.fill(0)` historically optimises
269        // less reliably; (2) it sidesteps the need to construct a `&mut [u8]`
270        // alias over the slab, which is awkward to do soundly while another
271        // thread may hold a different `&mut [u8]` slice into a disjoint
272        // region of the same slab.
273        //
274        // Finding (PERF, allocate whole-region memset defeats the
275        // sub-microsecond pool goal for large slabs):
276        // -------------------------------------------------------------------
277        // Cost: O(size) per allocation. For a 256 MiB Wasm linear memory this
278        // is on the order of tens of milliseconds — large enough that callers
279        // doing many small allocations should batch where possible, but
280        // unavoidable for correctness given the recycle discipline. Skipping
281        // it would re-open the H1 cross-tenant disclosure window.
282        //
283        // DECISION: the memset is REQUIRED and is kept at full `size`; the
284        // "zero only the visible window" narrowing CANNOT be applied at this
285        // layer. `allocate` takes a single `size` and has no `minimum` /
286        // visible-window parameter, and the `PoolAllocation` it returns hands
287        // the caller the FULL `[0, size)` range immediately via
288        // `as_slice()` / `as_mut_slice()` (see the public accessors below).
289        // There is no `grow_to` step at the pool layer that would zero a tail
290        // before first read, and several callers — including the H1 regression
291        // tests `recycled_allocation_reads_as_zero` and
292        // `first_allocation_reads_as_zero`, plus the cross-tenant
293        // `tests/cudarc_visible_window_only.rs` — rely on every byte of the
294        // carved region reading zero on hand-out. Narrowing the zero-fill to a
295        // prefix here would leave the recycled tail observable and re-open the
296        // H1 cross-tenant disclosure window: a soundness regression, not a
297        // safe optimization. (The wasm path's tail IS additionally re-zeroed
298        // by `PooledLinearMemory::grow_to`, but the pool API contract is wider
299        // than that single consumer, so the conservative full memset is the
300        // only sound choice at this layer.)
301        //
302        // PERF note (considered, intentionally NOT applied here): the
303        // `UnifiedBuffer` visible-window path can zero only `[0, minimum)` and
304        // lean on `grow_to`'s tail zero-fill because the bytes past `minimum`
305        // are not host-visible until a later `grow_to` zeroes them on the way
306        // in. That optimization does NOT transfer to the pool layer: a
307        // `PoolAllocation` exposes the FULL carved `[0, size)` range to the
308        // caller immediately (there is no `minimum`/visible-window narrowing
309        // and no `grow_to` step that would zero a tail before first read), so
310        // every byte we hand out is reachable right away. Zeroing only a
311        // prefix here would leave the recycled tail observable and re-open the
312        // H1 cross-tenant disclosure window — a soundness regression. The
313        // whole-region `memset` is therefore the correct conservative choice.
314        //
315        // SAFETY: `aligned_bump + size <= self.slab.len()` (checked above);
316        // the slab's pointer is non-null and points to `len()` valid bytes
317        // for the lifetime of `&self`; the bump allocator guarantees the
318        // `[aligned_bump, aligned_bump + size)` byte range is disjoint from
319        // every other live `PoolAllocation`, so this write cannot race
320        // another thread's `as_mut_slice()`.
321        unsafe {
322            let base = self.slab.as_ptr().add(aligned_bump) as *mut u8;
323            std::ptr::write_bytes(base, 0u8, size);
324        }
325
326        Ok(PoolAllocation {
327            pool: self,
328            offset: aligned_bump,
329            size,
330        })
331    }
332
333    /// Reset the bump pointer back to zero. Safe to call only when there are
334    /// no outstanding [`PoolAllocation`]s; returns an error otherwise.
335    ///
336    /// # Why `&mut self` (audit T4)
337    ///
338    /// `reset` rewinds the bump pointer so the next `allocate` will hand out
339    /// byte ranges that overlap with regions previously issued. If `reset`
340    /// took `&self`, a caller could legitimately hold a `&[u8]` derived from
341    /// an earlier allocation (or a raw pointer obtained via the slab base) at
342    /// the moment of reset, and a subsequent `allocate` would alias that
343    /// borrow with freshly-issued memory — a use-after-rewind UB hazard that
344    /// is invisible to the borrow checker because the bump pointer move only
345    /// requires a shared borrow of the interior `Mutex`. Requiring `&mut
346    /// self` reflects the actual aliasing contract: no other borrow of
347    /// `self` (and therefore no live `PoolAllocation`, no `slab_ptr`-derived
348    /// raw pointer that has been turned into a slice, etc.) may coexist with
349    /// a reset. Callers holding the pool through `Arc<UnifiedMemoryPool>`
350    /// must either drop every clone but one (so `Arc::get_mut` succeeds) or
351    /// switch to a per-tenant pool — see the Wasm-backing teardown contract
352    /// below.
353    ///
354    /// # Teardown contract for Wasm-backing pools (audit T6)
355    ///
356    /// Pool slabs that served a
357    /// [`crate::wasm_memory::TensorWasmMemoryCreator`]-issued pool-backed Wasm
358    /// linear memory (`PooledLinearMemory`) remain resettable: although
359    /// `PoolAllocation` drop guards are intentionally leaked at carve time
360    /// to keep the bump pointer monotonic (see the `std::mem::forget(alloc)`
361    /// site in
362    /// `TensorWasmMemoryCreator::new_memory`),
363    /// `PooledLinearMemory`'s own `Drop` impl now mirrors the leak by calling
364    /// `Self::release` when the linear memory itself is torn down. The
365    /// [`live_allocations`](Self::live_allocations) counter therefore returns
366    /// to zero once every issued pool-backed Wasm memory has been dropped, at
367    /// which point `reset` succeeds (the existing `live > 0` guard below is
368    /// unchanged; what changed is that `live` is now actually allowed to
369    /// reach zero again). The bump pointer is NOT rewound by `Drop` —
370    /// monotonic-bump semantics are preserved between explicit `reset` calls,
371    /// so any in-flight reads through a `base_ptr` carved out earlier remain
372    /// valid until reset is *explicitly* invoked (and reset is gated by
373    /// `&mut self`, which the type system refuses while any other
374    /// `Arc<UnifiedMemoryPool>` keepalive exists).
375    ///
376    /// Operators wanting per-tenant resets can therefore either:
377    /// (a) drop every issued memory then call `reset` on a uniquely-owned
378    ///     pool, or
379    /// (b) continue to use one pool per tenant and drop the pool wholesale
380    ///     at tenant teardown.
381    pub fn reset(&mut self) -> Result<(), UnifiedError> {
382        // `&mut self` guarantees no other thread holds *any* reference to
383        // `self`, so a non-atomic `get_mut().clone()` would be sound here.
384        // We still go through the atomic accessor for ergonomic symmetry
385        // with the rest of the module; `Ordering::Acquire` ensures any
386        // `release` decrement on another thread that this one has not yet
387        // synchronised with is visible (it must be, given the `&mut self`
388        // contract, but the explicit acquire keeps the code self-documenting).
389        let live_now = *self.live.get_mut();
390        if live_now != 0 {
391            return Err(UnifiedError::Allocation(format!(
392                "cannot reset: {live_now} live allocations outstanding",
393            )));
394        }
395        // Direct non-atomic writes through `get_mut()` — `&mut self` proves
396        // uniqueness, so this cannot race with any concurrent CAS in
397        // `allocate` (the type system forbids `&self` calls while `reset`
398        // is in flight). `issued_total` is sticky on purpose — it reflects
399        // lifetime activity.
400        *self.bump.get_mut() = 0;
401        Ok(())
402    }
403
404    /// Decrement the `live` counter for an allocation at `[offset, offset+size)`.
405    ///
406    /// Does NOT rewind the bump pointer — monotonic-bump semantics are
407    /// preserved (the carved region remains logically claimed against the
408    /// slab's lifetime; only [`Self::reset`] rewinds, and only when
409    /// `live == 0`). The `offset` and `size` parameters are accepted so
410    /// future per-region debug-assertions can match them against a
411    /// shadow free-list; today they are only validated by the live-count
412    /// underflow assert.
413    ///
414    /// Called from:
415    ///
416    /// * [`PoolAllocation::drop`] — the safe borrow-based release path.
417    /// * [`crate::wasm_memory::PooledLinearMemory::drop`] — the
418    ///   `mem::forget`-mirror release path (audit T6: previously the
419    ///   counter was held elevated forever once any pool-backed Wasm
420    ///   memory was issued, permanently blocking [`Self::reset`]; now
421    ///   the Wasm memory's own `Drop` decrements the counter so a
422    ///   subsequent reset can run once every issued memory has been
423    ///   dropped).
424    ///
425    /// Calling this more times than [`Self::allocate`] was called is a
426    /// logic bug — the `debug_assert!` below catches it in debug builds.
427    /// In release builds the counter saturates at zero (defensive: an
428    /// under-count is still safer than wrapping `usize`).
429    pub(crate) fn release(&self, _offset: usize, _size: usize) {
430        // Audit T26: `live` is an `AtomicUsize` (was previously behind the
431        // shared `Mutex<PoolState>`). We do the underflow-safe decrement
432        // in a single CAS loop so a buggy double-release saturates at zero
433        // in release builds rather than wrapping to `usize::MAX` —
434        // identical defensive behaviour to the original
435        // `saturating_sub(1)` under the mutex.
436        //
437        // `AcqRel` so a thread that later observes `live == 0` and proceeds
438        // to `reset()` has a happens-before chain to every prior release.
439        let mut current = self.live.load(Ordering::Acquire);
440        loop {
441            debug_assert!(
442                current > 0,
443                "UnifiedMemoryPool::release called more times than allocate \
444                 (would underflow live counter)"
445            );
446            let next = current.saturating_sub(1);
447            match self.live.compare_exchange_weak(
448                current,
449                next,
450                Ordering::AcqRel,
451                Ordering::Acquire,
452            ) {
453                Ok(_) => break,
454                Err(observed) => current = observed,
455            }
456        }
457    }
458
459    /// Raw pointer to the start of the slab (intended for tests / FFI).
460    ///
461    /// # Safety
462    ///
463    /// The returned pointer is only valid:
464    ///
465    /// 1. While `self` (or any borrow of it, including `Arc` keepalives) is
466    ///    alive — the slab is freed when the pool is dropped.
467    /// 2. While no `&mut self` method on the pool executes — notably
468    ///    [`Self::reset`], which rewinds the bump pointer and would let a
469    ///    subsequent [`Self::allocate`] hand out a `PoolAllocation` whose
470    ///    `&mut [u8]` aliases any slice the caller may have constructed
471    ///    from this pointer.
472    /// 3. Reads/writes through the pointer must not overlap any currently
473    ///    live [`PoolAllocation`]'s `[offset, offset + size)` byte range,
474    ///    since `PoolAllocation::as_mut_slice` proves uniqueness over that
475    ///    range and a parallel raw-pointer write would form an illegal
476    ///    `&mut` / `*mut` alias.
477    ///
478    /// The accessor is `pub(crate)` so external code cannot derive a slice
479    /// that outlives a future `reset()`; in-crate callers (tests, FFI
480    /// glue) must still satisfy the conditions above at every use site.
481    #[cfg(test)]
482    #[allow(dead_code)]
483    pub(crate) unsafe fn slab_ptr(&self) -> *const u8 {
484        self.slab.as_ptr()
485    }
486}
487
488impl<'p> PoolAllocation<'p> {
489    /// Byte length of this region.
490    pub fn len(&self) -> usize {
491        self.size
492    }
493
494    /// True if zero-length (never for a successfully created allocation).
495    pub fn is_empty(&self) -> bool {
496        self.size == 0
497    }
498
499    /// Offset within the underlying slab.
500    pub fn offset(&self) -> usize {
501        self.offset
502    }
503
504    /// Borrow as a shared byte slice.
505    pub fn as_slice(&self) -> &[u8] {
506        // SAFETY: the pool's slab is alive (we borrow `&'p UnifiedMemoryPool`)
507        // and we carved [offset, offset+size) out of it during allocation.
508        unsafe {
509            let base = self.pool.slab.as_ptr().add(self.offset);
510            std::slice::from_raw_parts(base, self.size)
511        }
512    }
513
514    /// Borrow as a mutable byte slice.
515    ///
516    /// `&mut self` and the disjoint-region invariant of bump allocation
517    /// together prove no other live alias points at `[offset, offset+size)`.
518    pub fn as_mut_slice(&mut self) -> &mut [u8] {
519        // SAFETY: see above; `&mut self` proves uniqueness of this PoolAllocation
520        // and the bump allocator guarantees disjoint regions across allocations.
521        unsafe {
522            let base = self.pool.slab.as_ptr().add(self.offset) as *mut u8;
523            std::slice::from_raw_parts_mut(base, self.size)
524        }
525    }
526}
527
528impl fmt::Debug for PoolAllocation<'_> {
529    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
530        f.debug_struct("PoolAllocation")
531            .field("offset", &self.offset)
532            .field("size", &self.size)
533            .finish()
534    }
535}
536
537impl Drop for PoolAllocation<'_> {
538    fn drop(&mut self) {
539        self.pool.release(self.offset, self.size);
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    #[test]
548    fn allocate_within_capacity() {
549        let pool = UnifiedMemoryPool::new(1024).unwrap();
550        assert_eq!(pool.capacity(), 1024);
551        let a = pool.allocate(100, 16).unwrap();
552        assert_eq!(a.len(), 100);
553        assert_eq!(a.offset() % 16, 0);
554        assert_eq!(pool.live_allocations(), 1);
555    }
556
557    #[test]
558    fn allocations_are_disjoint_and_aligned() {
559        let pool = UnifiedMemoryPool::new(4096).unwrap();
560        let a = pool.allocate(100, 64).unwrap();
561        let b = pool.allocate(200, 64).unwrap();
562        assert!(b.offset() >= a.offset() + a.len());
563        assert_eq!(a.offset() % 64, 0);
564        assert_eq!(b.offset() % 64, 0);
565    }
566
567    #[test]
568    fn exhaustion_returns_too_large_with_figures() {
569        // Exhaustion is now reported via the structured `TooLarge` variant
570        // (carrying the requested size and the slab capacity) so the
571        // `From<UnifiedError> for TensorWasmError` impl can route the
572        // failure to `MemoryExhausted { requested, limit }` without
573        // substring-matching on a message.
574        let pool = UnifiedMemoryPool::new(128).unwrap();
575        let _a = pool.allocate(64, 16).unwrap();
576        let err = pool.allocate(128, 16).expect_err("should exhaust");
577        match err {
578            UnifiedError::TooLarge { requested, limit } => {
579                assert_eq!(requested, 128);
580                assert_eq!(limit, 128);
581            }
582            other => panic!("expected TooLarge, got {other:?}"),
583        }
584    }
585
586    #[test]
587    fn drop_decrements_live() {
588        let pool = UnifiedMemoryPool::new(256).unwrap();
589        {
590            let _a = pool.allocate(32, 1).unwrap();
591            assert_eq!(pool.live_allocations(), 1);
592        }
593        assert_eq!(pool.live_allocations(), 0);
594    }
595
596    #[test]
597    fn reset_only_when_empty() {
598        // `reset` now takes `&mut self` (audit T4). The type system already
599        // refuses to reset while a `PoolAllocation` borrow is live — there is
600        // no longer a way to call `reset` with `&self`-derived aliases alive
601        // through ordinary safe code. But the runtime live-count guard inside
602        // `reset` still matters for the `wasm_memory::TensorWasmMemoryCreator`
603        // path that intentionally `mem::forget`s the `PoolAllocation` to keep
604        // the bump pointer monotonic across pooled Wasm instances; for that
605        // caller the borrow is gone but the live counter is incremented, so the
606        // runtime check is the last line of defence. Simulate that scenario
607        // here by forgetting an allocation to bump the live count without
608        // holding a borrow that would conflict with `&mut pool`.
609        //
610        // (Audit T6: in production, `PooledLinearMemory::Drop` calls
611        // `release()` to mirror this walk-down automatically, so a `reset`
612        // after every issued Wasm memory has been dropped will succeed.
613        // This unit test reproduces that walk-down by hand to keep the
614        // pool-module test self-contained.)
615        let mut pool = UnifiedMemoryPool::new(256).unwrap();
616        let a = pool.allocate(64, 1).unwrap();
617        // `mem::forget` releases the borrow without running `Drop`, so the
618        // live counter stays at 1 while `&mut pool` becomes obtainable.
619        std::mem::forget(a);
620        assert!(
621            pool.reset().is_err(),
622            "reset must fail while the live-allocations counter is non-zero"
623        );
624        // Walk the counter back down by hand (we forgot the drop guard) so
625        // we can exercise the success path. In production, this is exactly
626        // what `PooledLinearMemory::Drop` does for pool-backed Wasm memories.
627        pool.release(0, 64);
628        pool.reset().expect("reset must succeed when empty");
629        assert_eq!(pool.remaining(), pool.capacity());
630    }
631
632    #[test]
633    fn issued_total_is_sticky_across_reset() {
634        // See `reset_only_when_empty` for why this binding is `mut`.
635        let mut pool = UnifiedMemoryPool::new(1024).unwrap();
636        {
637            let _a = pool.allocate(64, 1).unwrap();
638            let _b = pool.allocate(64, 1).unwrap();
639        }
640        assert_eq!(pool.issued_total(), 128);
641        pool.reset().unwrap();
642        assert_eq!(pool.issued_total(), 128); // sticky
643    }
644
645    #[test]
646    fn invalid_alignment_rejected() {
647        let pool = UnifiedMemoryPool::new(256).unwrap();
648        assert!(pool.allocate(8, 0).is_err());
649        assert!(pool.allocate(8, 3).is_err()); // not a power of two
650        assert!(pool.allocate(0, 8).is_err());
651    }
652
653    #[test]
654    fn excessive_alignment_rejected() {
655        let pool = UnifiedMemoryPool::new(4096).unwrap();
656        // Power-of-two alignment beyond the 1 GiB cap must be rejected
657        // (would otherwise overflow when computing the aligned bump).
658        let huge = (1usize << 31) + 1; // > 2 GiB
659                                       // Use a clean power-of-two above the cap.
660        let too_big = 1usize << 31;
661        assert!(pool.allocate(8, too_big).is_err());
662        let _ = huge; // silence unused
663    }
664
665    #[test]
666    fn writes_visible_in_slice() {
667        let pool = UnifiedMemoryPool::new(256).unwrap();
668        let mut a = pool.allocate(16, 8).unwrap();
669        for (i, byte) in a.as_mut_slice().iter_mut().enumerate() {
670            *byte = i as u8;
671        }
672        for (i, &byte) in a.as_slice().iter().enumerate() {
673            assert_eq!(byte, i as u8);
674        }
675    }
676
677    #[test]
678    fn recycled_allocation_reads_as_zero() {
679        // Cross-tenant data-leak regression test (audit H1):
680        // -------------------------------------------------------------------
681        // Simulates the recycle path: tenant A writes 0xAB into a pool
682        // allocation, releases it, the pool is reset, and tenant B requests
683        // an allocation that lands on the same bytes. The recycled bytes
684        // MUST read as zero — otherwise tenant B can observe tenant A's
685        // private data. This pins the `ptr::write_bytes` zero-fill added in
686        // `UnifiedMemoryPool::allocate`.
687        const SIZE: usize = 4 * 1024; // 4 KiB — large enough to detect any
688                                      // off-by-one in the memset bounds.
689                                      // `reset` now takes `&mut self` (audit T4); see `reset_only_when_empty`.
690        let mut pool = UnifiedMemoryPool::new(64 * 1024).unwrap();
691
692        // Tenant A: poison every byte with the sentinel.
693        {
694            let mut a = pool.allocate(SIZE, 64).unwrap();
695            a.as_mut_slice().fill(0xAB);
696            // sanity: poison actually went in
697            assert!(a.as_slice().iter().all(|&b| b == 0xAB));
698        }
699        // Reset the bump pointer so the same byte range is reachable again.
700        pool.reset().expect("reset after tenant A drops");
701
702        // Tenant B: ask for the same shape and assert every byte reads zero.
703        let b = pool.allocate(SIZE, 64).unwrap();
704        let leaked: Vec<usize> = b
705            .as_slice()
706            .iter()
707            .enumerate()
708            .filter_map(|(i, &v)| if v != 0 { Some(i) } else { None })
709            .collect();
710        assert!(
711            leaked.is_empty(),
712            "recycled pool allocation leaked tenant-A data at {} byte offsets (first: {:?})",
713            leaked.len(),
714            leaked.first(),
715        );
716    }
717
718    #[test]
719    fn pool_concurrent_allocate_cas() {
720        // Audit T26: the CAS-bump replacement must keep the disjoint-region
721        // invariant intact under concurrent pressure. Spawn 16 threads, each
722        // performing N allocations of 1000 bytes (alignment 1 so the
723        // `aligned_bump` arithmetic is a no-op and the total consumed equals
724        // exactly `threads * iters * 1000`), collect every offset, and assert:
725        //
726        //   (a) the final `bump` equals `threads * iters * 1000` — no CAS
727        //       loss, no double-bump;
728        //   (b) `live_allocations` equals `threads * iters` — every allocation
729        //       incremented `live` exactly once;
730        //   (c) the set of offsets is pairwise non-overlapping — disjoint
731        //       allocation invariant survived the mutex removal.
732        //
733        // We intentionally `std::mem::forget` every allocation so the live
734        // counter is held high; the test owns the pool exclusively so it can
735        // drive the counter back to zero at the end via `release`.
736        use std::sync::Arc;
737        use std::thread;
738
739        const THREADS: usize = 16;
740        const ITERS: usize = 1000;
741        const SIZE: usize = 1000;
742
743        let pool = Arc::new(UnifiedMemoryPool::new(THREADS * ITERS * SIZE + 4096).expect("pool"));
744
745        let mut handles = Vec::with_capacity(THREADS);
746        for _ in 0..THREADS {
747            let pool = Arc::clone(&pool);
748            handles.push(thread::spawn(move || {
749                let mut offsets = Vec::with_capacity(ITERS);
750                for _ in 0..ITERS {
751                    let a = pool.allocate(SIZE, 1).expect("alloc");
752                    offsets.push((a.offset(), a.len()));
753                    // Forget the drop guard so the live counter stays
754                    // elevated; we walk it back down at the end. This also
755                    // means the bump pointer cannot accidentally rewind via
756                    // `reset` between iterations.
757                    std::mem::forget(a);
758                }
759                offsets
760            }));
761        }
762
763        let mut all_offsets: Vec<(usize, usize)> = Vec::with_capacity(THREADS * ITERS);
764        for h in handles {
765            all_offsets.extend(h.join().expect("thread join"));
766        }
767
768        // (a) total bump consumed.
769        assert_eq!(
770            pool.capacity() - pool.remaining(),
771            THREADS * ITERS * SIZE,
772            "total consumed bytes must equal threads * iters * size; \
773             a discrepancy means a CAS update was lost or doubled"
774        );
775
776        // (b) live count.
777        assert_eq!(
778            pool.live_allocations(),
779            THREADS * ITERS,
780            "every allocation must increment `live` exactly once"
781        );
782
783        // (c) pairwise disjoint regions. Sort by offset; then walk and
784        // assert each region starts at or after the previous one ended.
785        all_offsets.sort_unstable_by_key(|&(off, _)| off);
786        for w in all_offsets.windows(2) {
787            let (off_a, len_a) = w[0];
788            let (off_b, _len_b) = w[1];
789            assert!(
790                off_a + len_a <= off_b,
791                "concurrent allocations overlap: [{off_a}, {}) vs [{off_b}, ...) \
792                 — CAS bump did not preserve the disjoint-region invariant",
793                off_a + len_a,
794            );
795        }
796
797        // Drain the live counter by hand so the pool's Drop is clean.
798        for (off, len) in all_offsets {
799            pool.release(off, len);
800        }
801        assert_eq!(pool.live_allocations(), 0);
802    }
803
804    #[test]
805    fn first_allocation_reads_as_zero() {
806        // Companion to `recycled_allocation_reads_as_zero`: even the *first*
807        // allocation out of a fresh pool must read as zero. On the heap
808        // backing (`Box<[u8]>`) this is trivially true because `vec![0u8; n]`
809        // zeroes. On the cust path, `cust::memory::UnifiedBuffer::new(&0u8,
810        // size)` seeds with zero. On the cudarc path, `cuMemAllocManaged`
811        // does NOT zero — the `unified.rs` change closes that hole. Either
812        // way the contract surfaced by `UnifiedMemoryPool::allocate` must be
813        // identical across all three backings, so we test it here.
814        let pool = UnifiedMemoryPool::new(4 * 1024).unwrap();
815        let a = pool.allocate(1024, 16).unwrap();
816        assert!(
817            a.as_slice().iter().all(|&b| b == 0),
818            "first allocation out of fresh pool must read as zero"
819        );
820    }
821}