Skip to main content

tensor_wasm_mem/
wasm_memory.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Wasmtime [`MemoryCreator`] backed by [`UnifiedBuffer`].
5//!
6//! When a Wasm module declares a `(memory 1)` (one page), Wasmtime asks the
7//! [`MemoryCreator`] to materialise that memory. We pre-allocate the requested
8//! `maximum` (or a default cap), so growth is just a size check — no realloc
9//! and no host-to-device copy. The `as_ptr` returned points directly into
10//! Unified Memory on CUDA hosts, meaning CUDA kernels can read/write the
11//! buffer the Wasm guest sees, with no explicit DMA.
12//!
13//! # `memory_protection_keys` — known gap vs the S5 plan
14//!
15//! The S5 plan calls for enabling Wasmtime's `memory_protection_keys` for
16//! intra-process Wasm isolation "where available". In wasmtime 25 this knob
17//! lives on `PoolingAllocationConfig::memory_protection_keys`, NOT on
18//! `Config`, and it is mutually exclusive with `Config::with_host_memory`:
19//! pooling MPK colours wasmtime-owned slabs, whereas this crate's whole
20//! purpose is to hand Wasmtime host-owned `UnifiedBuffer` memories. Inter-
21//! tenant isolation therefore relies on (a) Wasmtime's Cranelift-emitted
22//! bounds checks on every Wasm load/store, (b) each tenant owning a
23//! distinct `UnifiedBuffer` with no shared backing store, and (c) the
24//! per-instance stream and per-tenant context machinery wired up in S7
25//! (`tensor-wasm-exec`) and S16 (`tensor-wasm-tenant`) — not on CPU protection keys
26//! and not on OS-level page guards (managed memory is migrated by the
27//! CUDA driver, which is incompatible with host `mprotect`). See
28//! `SECURITY.md` at the repo root for the full threat model.
29//!
30//! MPK is now available as an *alternate* engine mode via
31//! `tensor_wasm_exec::engine::MemoryBackend::PoolingMpk`, at the explicit cost of
32//! dropping `TensorWasmMemoryCreator` / `UnifiedBuffer` integration (and therefore
33//! the GPU integration path). Operators can pick the mode that fits their
34//! workload: `UnifiedBuffer` for the GPU integration path described above,
35//! or `PoolingMpk` for CPU-only / batch-GPU workloads that want intra-process
36//! Wasm isolation via CPU PKU. The architectural exclusivity between the
37//! two modes is real and enforced by Wasmtime.
38
39use std::ops::Range;
40use std::sync::Arc;
41
42use wasmtime::{LinearMemory, MemoryCreator, MemoryType};
43
44use crate::pool::UnifiedMemoryPool;
45use crate::unified::{DeviceId, UnifiedBuffer, UnifiedError};
46
47/// Default maximum capacity, in bytes, when a Wasm module declares no upper
48/// bound on its linear memory. 256 MiB matches the plan's working-set cap.
49pub const DEFAULT_MAX_BYTES: usize = 256 * 1024 * 1024;
50
51/// Absolute upper bound on linear-memory capacity per Wasm instance.
52///
53/// Wasmtime's [`wasmtime::ResourceLimiter::memory_growing`] only fires on
54/// `memory.grow` — NOT on the initial allocation a module declares with
55/// `(memory N M)`. A malicious or buggy guest can therefore force the host
56/// to allocate up to 4 GiB (the spec maximum for a 32-bit Wasm memory:
57/// 65 536 pages × 64 KiB) at instantiation time without ever calling
58/// `memory.grow`. This constant is the hard ceiling enforced by
59/// [`TensorWasmLinearMemory::new_on`] and
60/// [`TensorWasmMemoryCreator::new_memory`]; oversize requests are rejected
61/// with [`UnifiedError::TooLarge`] before any backing allocation happens.
62///
63/// 4 GiB matches the wasm32 spec maximum so a compliant module that
64/// reserves the full address space is still admitted (subject to the
65/// per-engine `EngineConfig::max_memory_bytes`); anything larger than the
66/// spec maximum is fail-fast on the host side.
67pub const HARD_MAX_LINEAR_MEMORY_BYTES: usize = 4 * 1024 * 1024 * 1024;
68
69/// Resolve the effective backing-allocation cap from Wasmtime's reported
70/// `maximum`.
71///
72/// Wasmtime reports a linear memory that declares **no explicit upper bound**
73/// as the wasm32 architectural ceiling ([`HARD_MAX_LINEAR_MEMORY_BYTES`], 4 GiB)
74/// rather than `None`. Because this creator pre-allocates the whole cap up
75/// front (so `memory.grow` is just a size check — see [`TensorWasmLinearMemory`]),
76/// an unbounded `(memory N)` — the overwhelmingly common shape — would otherwise
77/// eagerly allocate the full 4 GiB at instantiation. That aborts the process on
78/// hosts without memory overcommit (e.g. Windows: `memory allocation of
79/// 4294967296 bytes failed`) and wastes address space everywhere else, defeating
80/// the [`DEFAULT_MAX_BYTES`] default-cap the docs promise for unbounded memories.
81///
82/// So `None` *and* an at-or-above-ceiling `maximum` both resolve to
83/// [`DEFAULT_MAX_BYTES`]; an explicit bound below the ceiling is honoured
84/// verbatim. (`> HARD_MAX_LINEAR_MEMORY_BYTES` is unreachable for wasm32 — its
85/// maximum is the ceiling — but the callers keep the `TooLarge` reject as
86/// defence for any future memory64 path.)
87fn effective_max_bytes(maximum_bytes: Option<usize>) -> usize {
88    match maximum_bytes {
89        // No declared bound → default cap.
90        None => DEFAULT_MAX_BYTES,
91        // Exactly the wasm32 architectural ceiling is how Wasmtime reports an
92        // *unbounded* memory32 → treat as "no explicit bound" → default cap.
93        Some(m) if m == HARD_MAX_LINEAR_MEMORY_BYTES => DEFAULT_MAX_BYTES,
94        // An explicit bound (below the ceiling) is honoured verbatim; a
95        // genuinely-oversized request (> ceiling, e.g. a synthetic/memory64
96        // declaration) is returned unchanged so the caller's `> HARD_MAX`
97        // `TooLarge` reject still fires (and reports the real requested size).
98        Some(m) => m,
99    }
100}
101
102/// A Wasm linear memory backed by [`UnifiedBuffer`].
103///
104/// The buffer is allocated at construction time with the requested *maximum*
105/// (or [`DEFAULT_MAX_BYTES`]) so `grow_to` becomes a size check. This avoids
106/// the cost of `cudaMemcpy` on every `memory.grow` and keeps the kernel-side
107/// pointer stable across growth events.
108#[derive(Debug)]
109pub struct TensorWasmLinearMemory {
110    buffer: UnifiedBuffer,
111    /// Currently-visible (logical) size. `<= buffer.len()`. Mutation goes
112    /// through `&mut self` (see `LinearMemory::grow_to`), so no interior
113    /// lock is required; `Send + Sync` are auto-derived from the fields.
114    current_size: usize,
115    /// Hard cap. Always `<= buffer.len()`.
116    maximum_size: usize,
117}
118
119impl TensorWasmLinearMemory {
120    /// Create a new linear memory.
121    ///
122    /// - `minimum_bytes`: initial visible size (Wasm pages × 65 536).
123    /// - `maximum_bytes`: cap on growth. If `None`, [`DEFAULT_MAX_BYTES`] is used.
124    pub fn new(minimum_bytes: usize, maximum_bytes: Option<usize>) -> Result<Self, UnifiedError> {
125        Self::new_on(minimum_bytes, maximum_bytes, DeviceId::default())
126    }
127
128    /// Same as [`new`](Self::new) but on a specific device.
129    ///
130    /// Fails with [`UnifiedError::TooLarge`] when `maximum_bytes` (or the
131    /// `minimum_bytes` floor) would exceed [`HARD_MAX_LINEAR_MEMORY_BYTES`].
132    /// This closes mem-H5 / exec-S-2 / exec-S-10: Wasmtime's
133    /// [`wasmtime::ResourceLimiter::memory_growing`] only fires on
134    /// `memory.grow`, so a guest declaring `(memory 1 65536)` would
135    /// otherwise force a 4 GiB allocation at instantiation. The hard cap
136    /// is enforced *before* the backing allocator is invoked.
137    pub fn new_on(
138        minimum_bytes: usize,
139        maximum_bytes: Option<usize>,
140        device_id: DeviceId,
141    ) -> Result<Self, UnifiedError> {
142        let max = effective_max_bytes(maximum_bytes);
143        if max > HARD_MAX_LINEAR_MEMORY_BYTES {
144            return Err(UnifiedError::TooLarge {
145                requested: max as u64,
146                limit: HARD_MAX_LINEAR_MEMORY_BYTES as u64,
147            });
148        }
149        if minimum_bytes > HARD_MAX_LINEAR_MEMORY_BYTES {
150            return Err(UnifiedError::TooLarge {
151                requested: minimum_bytes as u64,
152                limit: HARD_MAX_LINEAR_MEMORY_BYTES as u64,
153            });
154        }
155        if minimum_bytes > max {
156            return Err(UnifiedError::Allocation(format!(
157                "minimum {minimum_bytes} > maximum {max}"
158            )));
159        }
160        // Allocate at least 1 byte so the underlying allocator never sees zero.
161        let cap = max.max(1);
162        // Only zero the visible window. Wasm semantics require the initial
163        // `minimum_bytes` to read as zero; any bytes later exposed by
164        // `memory.grow` are zero-filled by Wasmtime itself. On the cust path
165        // this avoids paying a `cap`-sized memset on every Wasm spawn — a
166        // 256 MiB cost under the default `DEFAULT_MAX_BYTES` cap.
167        let buffer = UnifiedBuffer::new_with_visible_window_on(cap, minimum_bytes, device_id)?;
168        Ok(Self {
169            buffer,
170            current_size: minimum_bytes,
171            maximum_size: max,
172        })
173    }
174
175    /// Tenant-aware variant of [`Self::new_on`].
176    ///
177    /// Routes the underlying [`UnifiedBuffer`] allocation through
178    /// [`UnifiedBuffer::new_with_visible_window_on_with_tenant_context`]
179    /// so the tenant's GPU memory cap is consulted before allocation
180    /// and `release_gpu_bytes(cap)` is called on Drop. The same
181    /// `HARD_MAX_LINEAR_MEMORY_BYTES` ceiling and `min > max` checks
182    /// run first so a cap violation never races against a guest-bug
183    /// rejection.
184    ///
185    /// Roadmap feature #8 path: invoked by
186    /// `TensorWasmMemoryCreator::with_tenant_context` /
187    /// `TensorWasmMemoryCreator::with_pool_and_tenant_context` whenever
188    /// they fall through to the no-pool fresh-allocation branch.
189    pub fn new_on_with_tenant_context(
190        minimum_bytes: usize,
191        maximum_bytes: Option<usize>,
192        device_id: DeviceId,
193        tenant_ctx: Arc<tensor_wasm_tenant::TenantContext>,
194    ) -> Result<Self, tensor_wasm_core::error::TensorWasmError> {
195        let max = effective_max_bytes(maximum_bytes);
196        if max > HARD_MAX_LINEAR_MEMORY_BYTES {
197            return Err(UnifiedError::TooLarge {
198                requested: max as u64,
199                limit: HARD_MAX_LINEAR_MEMORY_BYTES as u64,
200            }
201            .into());
202        }
203        if minimum_bytes > HARD_MAX_LINEAR_MEMORY_BYTES {
204            return Err(UnifiedError::TooLarge {
205                requested: minimum_bytes as u64,
206                limit: HARD_MAX_LINEAR_MEMORY_BYTES as u64,
207            }
208            .into());
209        }
210        if minimum_bytes > max {
211            return Err(UnifiedError::Allocation(format!(
212                "minimum {minimum_bytes} > maximum {max}"
213            ))
214            .into());
215        }
216        let cap = max.max(1);
217        let buffer = UnifiedBuffer::new_with_visible_window_on_with_tenant_context(
218            cap,
219            minimum_bytes,
220            device_id,
221            tenant_ctx,
222        )?;
223        Ok(Self {
224            buffer,
225            current_size: minimum_bytes,
226            maximum_size: max,
227        })
228    }
229
230    /// Current logical size in bytes.
231    pub fn current_size(&self) -> usize {
232        self.current_size
233    }
234
235    /// Pre-allocated capacity (the hard cap).
236    pub fn capacity(&self) -> usize {
237        self.maximum_size
238    }
239
240    /// Whether the underlying linear-memory backing is CUDA Unified Memory.
241    ///
242    /// Returns `true` when the crate was compiled with EITHER
243    /// `--features unified-memory` (cust path) OR `--features cudarc-backend`
244    /// (the W1.2 spike, used as the `Backing::Cudarc` variant when
245    /// `unified-memory` is off — see the precedence table in
246    /// [`crate::unified`]). This is the compile-time probe that closes the
247    /// v0.3.2 audit's "wasm linear memory not UVM-backed" gap: a guest
248    /// pointer resolved through the W1.1 wasi-cuda kernel-args pipeline
249    /// doubles as a device pointer iff this returns `true`. The pool-backed
250    /// `PooledLinearMemory` path also goes through `UnifiedBuffer` under
251    /// the hood, so it shares this property regardless of which CUDA
252    /// backing feature is active.
253    ///
254    /// # Memory-growth semantics
255    ///
256    /// `cuMemAllocManaged` returns a fixed-size allocation that cannot be
257    /// resized in place. We therefore pre-allocate the requested
258    /// `maximum_bytes` (or [`DEFAULT_MAX_BYTES`]) at construction time and
259    /// treat [`LinearMemory::grow_to`] as a logical-size bump up to that
260    /// cap (option (a) from the v0.3.3 design tracker). This matches
261    /// Wasmtime's `static` memory model, keeps the kernel-side pointer
262    /// stable across growth events, and keeps the hot path zero-copy at
263    /// the cost of reserving the worst-case footprint up front. Growing
264    /// the *physical* allocation (option (b): allocate-copy-free) is a
265    /// v0.4 follow-up tracked in `docs/RISKS.md`.
266    pub fn is_uvm_backed(&self) -> bool {
267        self.buffer.is_uvm_backed()
268    }
269
270    /// Borrow the buffer as a shared byte slice over the *current* size.
271    ///
272    /// # Safety contract
273    ///
274    /// This method is `pub(crate)` because the returned `&[u8]` aliases the
275    /// same bytes that Wasmtime mutates through [`LinearMemory::as_ptr`]
276    /// while the owning `Store` is executing guest code. The caller MUST NOT
277    /// hold the returned slice across any operation that may transfer control
278    /// to the guest (e.g. `TypedFunc::call`) — doing so would race a `&[u8]`
279    /// against the guest's mutable view of its linear memory, which is UB.
280    /// Intended uses: host-side inspection in tests and the trusted host
281    /// helpers within this crate, between guest invocations.
282    #[cfg(test)]
283    pub(crate) fn as_slice(&self) -> &[u8] {
284        let size = self.current_size;
285        // SAFETY: the underlying buffer covers `maximum_size >= size` bytes.
286        // Aliasing with the guest is the caller's responsibility per the
287        // doc-comment safety contract above.
288        unsafe { std::slice::from_raw_parts(self.buffer.as_ptr(), size) }
289    }
290}
291
292unsafe impl LinearMemory for TensorWasmLinearMemory {
293    fn byte_size(&self) -> usize {
294        self.current_size()
295    }
296
297    fn byte_capacity(&self) -> usize {
298        // Reserve-at-max design: the full ceiling is physically allocated up
299        // front (see `TensorWasmLinearMemory::new_on`), so the current
300        // allocation's capacity is exactly the maximum. (Replaces wasmtime
301        // <45's `maximum_byte_size(&self) -> Option<usize>`.)
302        self.maximum_size
303    }
304
305    fn grow_to(&mut self, new_size: usize) -> wasmtime::Result<()> {
306        if new_size > self.maximum_size {
307            return Err(wasmtime::Error::msg(format!(
308                "memory.grow requested {new_size} > maximum {}",
309                self.maximum_size
310            )));
311        }
312        if new_size < self.current_size {
313            return Err(wasmtime::Error::msg(format!(
314                "memory.grow cannot shrink ({new_size} < current {})",
315                self.current_size
316            )));
317        }
318        // Cross-tenant data-leak mitigation (audit H2):
319        // -------------------------------------------------------------------
320        // The WebAssembly spec requires bytes newly exposed by `memory.grow`
321        // to read as zero. Because we pre-allocate the entire `maximum_size`
322        // up front and only bump `current_size`, the host backing already
323        // contains *whatever the allocator left there* in the
324        // `[current_size, maximum_size)` window. Wasmtime does NOT zero
325        // host-supplied memory it didn't allocate, so without an explicit
326        // zero-fill here a guest could observe (a) the previous tenant's
327        // data if this buffer came out of a recycled `UnifiedMemoryPool`
328        // slab, or (b) uninitialised driver memory from `cuMemAllocManaged`.
329        //
330        // Zero the freshly-exposed range BEFORE the visible-size bump so a
331        // concurrent reader (e.g. host-side telemetry via `as_slice()`) that
332        // observes the new size also observes the zero bytes — never an
333        // intermediate state where `current_size` has grown but the bytes
334        // are still stale.
335        let old_size = self.current_size;
336        if new_size > old_size {
337            // `buffer.as_mut_slice()` covers `maximum_size >= new_size` bytes,
338            // so this range is in-bounds. The `&mut self` borrow on `grow_to`
339            // proves no concurrent reader can hold a `&[u8]` alias.
340            self.buffer.as_mut_slice()[old_size..new_size].fill(0);
341        }
342        self.current_size = new_size;
343        Ok(())
344    }
345
346    fn as_ptr(&self) -> *mut u8 {
347        // SAFETY: returning the raw underlying pointer is the contract of
348        // LinearMemory. Wasmtime guarantees synchronised access via its own
349        // borrow tracking.
350        self.buffer.as_ptr() as *mut u8
351    }
352}
353
354impl TensorWasmLinearMemory {
355    /// Native address range the guest can access — the full reservation,
356    /// including the reserved-but-not-yet-grown tail. wasmtime <45 called this
357    /// through the `LinearMemory` trait; wasmtime 45 dropped it (fault
358    /// classification now derives the range from `byte_capacity()`), so it is
359    /// retained as an inherent diagnostic helper exercised by this crate's
360    /// tests. `allow(dead_code)` because the non-test lib build has no caller.
361    #[allow(dead_code)]
362    pub(crate) fn wasm_accessible(&self) -> Range<usize> {
363        // Finding (MEDIUM, wasm_accessible reports full cap, not visible size):
364        // ------------------------------------------------------------------
365        // DECISION: keep the FULL reservation `[base, base + maximum_size)`;
366        // do NOT narrow to `current_size`.
367        //
368        // Rationale (verified against wasmtime 25.0.3):
369        //   * The `LinearMemory::wasm_accessible` contract is "the range of
370        //     native addresses that WebAssembly can natively access from this
371        //     linear memory, INCLUDING guard pages" — i.e. the whole backing
372        //     reservation, not the currently-grown window. Wasmtime's own
373        //     `MmapMemory`/`StaticMemory` impls return the entire mapping
374        //     (`mmap.len()` / `memory_and_guard_size`), which is strictly
375        //     larger than `byte_size()`; the asymmetry with `byte_size()` is
376        //     by design.
377        //   * The ONLY consumer in wasmtime 25 is `Instance::wasm_fault()`
378        //     (runtime/vm/instance.rs), the SIGSEGV/trap classifier that maps
379        //     a faulting host address back to a wasm offset for diagnostics.
380        //     It is NOT used in Cranelift bounds-check codegen, nor in any
381        //     pooling/static-memory reservation logic (those read
382        //     `byte_size()` / `byte_capacity()` and the
383        //     `VMMemoryDefinition.current_length`). The premise that this
384        //     value feeds bounds reasoning does not hold for this version.
385        //   * Reporting the full cap is therefore the CORRECT classification
386        //     window: an OOB store landing in the reserved-but-not-yet-grown
387        //     tail `[current_size, maximum_size)` is a genuine wasm trap that
388        //     belongs to THIS memory, and narrowing to `current_size` would
389        //     mis-attribute it (worse diagnostics), not improve safety.
390        //   * No UB / unmapped-address hazard: the entire `cap` is physically
391        //     allocated up front (`new_with_visible_window_on`); only ZEROING
392        //     is lazy. The `[current_size, maximum_size)` bytes are mapped and
393        //     addressable — the fault handler only reads the address range, it
394        //     never dereferences these bytes. They are guaranteed zero before
395        //     ever becoming guest-visible by the grow-time zero-fill in
396        //     `grow_to` above (audit H2), so the cross-tenant guarantee is
397        //     unaffected by what this method reports.
398        let base = self.buffer.as_ptr() as usize;
399        base..(base + self.maximum_size)
400    }
401}
402
403/// Wasm linear memory carved out of an [`UnifiedMemoryPool`] slab.
404///
405/// Holds an `Arc<UnifiedMemoryPool>` keepalive so the slab outlives the Wasm
406/// instance, plus a raw pointer + length into the slab's bytes, plus the
407/// `(offset, size)` of the carved region (kept so `Drop` can call
408/// [`UnifiedMemoryPool::release`] with the same figures the original
409/// [`crate::pool::PoolAllocation`] would have used).
410///
411/// # Invariants
412///
413/// The [`crate::pool::PoolAllocation`] drop guard returned by
414/// [`crate::pool::UnifiedMemoryPool::allocate`] is intentionally leaked at
415/// construction time (see `TensorWasmMemoryCreator::new_memory`); the bump
416/// pointer therefore never rewinds while this memory is alive, preserving
417/// monotonic-bump semantics across the pool's lifetime.
418///
419/// On Drop:
420///
421/// * the pool's `live` counter IS decremented via
422///   [`UnifiedMemoryPool::release`] — this closes the audit T6 finding
423///   that `live` was leaked permanently elevated, which permanently blocked
424///   [`UnifiedMemoryPool::reset`];
425/// * the bump pointer is NOT rewound — monotonic-bump semantics are
426///   preserved, so any `base_ptr` carved out later (and into a different
427///   `PooledLinearMemory`) keeps pointing at distinct bytes;
428/// * the reserved `[offset, offset+size)` region is logically freed and
429///   will be zero-filled on next reuse by the per-allocation zero-fill in
430///   [`UnifiedMemoryPool::allocate`] (so cross-tenant data leaks via
431///   recycled-byte reads stay closed, audit H1).
432struct PooledLinearMemory {
433    /// Keeps the slab alive while this memory is in use AND provides the
434    /// `release` callback the `Drop` impl invokes. Read by `Drop`.
435    pool_keepalive: Arc<UnifiedMemoryPool>,
436    /// Bump offset returned by `pool.allocate(...)` at construction time.
437    /// Passed back to [`UnifiedMemoryPool::release`] in `Drop` so future
438    /// debug-assertions can match it against a shadow free-list; today
439    /// `release` only uses it for symmetry with `PoolAllocation::Drop`.
440    pool_offset: usize,
441    /// Pointer to the first byte of the carved region (== slab base
442    /// + `pool_offset`).
443    base_ptr: *mut u8,
444    /// Bytes carved (the hard cap). This is the total reserved size,
445    /// distinct from any currently-grown visible window (`current_size`).
446    /// Passed to [`UnifiedMemoryPool::release`] alongside `pool_offset`.
447    size: usize,
448    /// Currently-visible (logical) size. `<= size`.
449    current_size: usize,
450    /// Hard cap exposed to Wasm. Equals `size`.
451    max_size: usize,
452}
453
454impl Drop for PooledLinearMemory {
455    fn drop(&mut self) {
456        // Audit T6: mirror the `std::mem::forget(alloc)` side-effect onto our
457        // own teardown so the pool's `live` counter returns to zero once
458        // every pool-backed Wasm memory has been dropped. This is what makes
459        // `UnifiedMemoryPool::reset` runnable after the all-released-at-end
460        // teardown the pool documents — without this `Drop`, the leaked
461        // `PoolAllocation` kept `live` elevated forever, permanently blocking
462        // reset.
463        //
464        // Crucially, the bump pointer is NOT rewound here — only the live
465        // counter is decremented. Monotonic-bump semantics are preserved
466        // (any other `PooledLinearMemory` still alive keeps a valid
467        // `base_ptr`); the bump pointer only rewinds when the operator
468        // explicitly calls `reset()`, which is gated by `&mut self` and
469        // therefore impossible while any `Arc<UnifiedMemoryPool>` keepalive
470        // is held.
471        self.pool_keepalive.release(self.pool_offset, self.size);
472    }
473}
474
475impl std::fmt::Debug for PooledLinearMemory {
476    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
477        f.debug_struct("PooledLinearMemory")
478            .field("base_ptr", &self.base_ptr)
479            .field("size", &self.size)
480            .field("current_size", &self.current_size)
481            .field("max_size", &self.max_size)
482            .finish()
483    }
484}
485
486// SAFETY: `base_ptr` points into the slab owned by `pool_keepalive`, which is
487// an `Arc<UnifiedMemoryPool>`. `UnifiedMemoryPool` (via `UnifiedBuffer`) is
488// `Send + Sync` and the carved region is disjoint from every other pool
489// allocation by the bump allocator's invariant. Concurrent mutation through
490// the raw pointer requires external synchronisation — same contract as
491// `TensorWasmLinearMemory` and `Vec<u8>` once a `&mut [u8]` exists.
492unsafe impl Send for PooledLinearMemory {}
493unsafe impl Sync for PooledLinearMemory {}
494
495unsafe impl LinearMemory for PooledLinearMemory {
496    fn byte_size(&self) -> usize {
497        self.current_size
498    }
499
500    fn byte_capacity(&self) -> usize {
501        // The pooled slab is carved to its ceiling up front, so the current
502        // allocation's capacity is the maximum. (Replaces wasmtime <45's
503        // `maximum_byte_size(&self) -> Option<usize>`.)
504        self.max_size
505    }
506
507    fn grow_to(&mut self, new_size: usize) -> wasmtime::Result<()> {
508        if new_size > self.max_size {
509            return Err(wasmtime::Error::msg(format!(
510                "memory.grow requested {new_size} > maximum {}",
511                self.max_size
512            )));
513        }
514        if new_size < self.current_size {
515            return Err(wasmtime::Error::msg(format!(
516                "memory.grow cannot shrink ({new_size} < current {})",
517                self.current_size
518            )));
519        }
520        // Cross-tenant data-leak mitigation (audit H2): mirror the zero-fill
521        // discipline of `TensorWasmLinearMemory::grow_to`. The carved slab
522        // region was zeroed at `UnifiedMemoryPool::allocate` time, but
523        // anything the *guest itself* scribbled into `[current_size,
524        // new_size)` (which it could legally do via OOB writes that Wasmtime
525        // bounds-checked against the OLD `current_size` only — those checks
526        // pass for offsets up to old `current_size`, not `max_size`, but a
527        // future code path or a host-side helper writing into the
528        // pre-allocated tail would still be observable on grow). Zero the
529        // newly-exposed window before bumping the visible size so the spec's
530        // "newly accessible bytes read as zero" guarantee holds end-to-end.
531        let old_size = self.current_size;
532        if new_size > old_size {
533            // SAFETY: `base_ptr` points to `size >= new_size` valid bytes
534            // (size == max_size; new_size <= max_size checked above); the
535            // bump allocator guarantees the carved region is disjoint from
536            // every other `PoolAllocation`, and `&mut self` proves no
537            // concurrent reader observes this `PooledLinearMemory`.
538            unsafe {
539                std::ptr::write_bytes(self.base_ptr.add(old_size), 0u8, new_size - old_size);
540            }
541        }
542        self.current_size = new_size;
543        Ok(())
544    }
545
546    fn as_ptr(&self) -> *mut u8 {
547        self.base_ptr
548    }
549}
550
551impl PooledLinearMemory {
552    /// Native address range the guest can access — the full carved slab
553    /// reservation. Retained diagnostic helper; see the note on
554    /// `TensorWasmLinearMemory::wasm_accessible`.
555    #[allow(dead_code)]
556    pub(crate) fn wasm_accessible(&self) -> Range<usize> {
557        // Finding (MEDIUM, wasm_accessible reports full cap, not visible size):
558        // mirror the decision documented on
559        // `TensorWasmLinearMemory::wasm_accessible`. We keep the FULL carved
560        // reservation `[base_ptr, base_ptr + max_size)` rather than narrowing
561        // to `current_size`. In wasmtime 25 the sole consumer is the
562        // `Instance::wasm_fault()` trap classifier, whose contract wants the
563        // entire backing window (matching the runtime's own
564        // `Mmap`/`StaticMemory` impls); the carved tail
565        // `[current_size, max_size)` is physically present in the slab and is
566        // zero-filled before becoming guest-visible (audit H1 carve-time
567        // zero-fill + audit H2 grow-time zero-fill), so reporting it is both
568        // correct for fault attribution and free of any cross-tenant or UB
569        // hazard.
570        let base = self.base_ptr as usize;
571        base..(base + self.max_size)
572    }
573}
574
575/// Tunable knobs for [`TensorWasmMemoryCreator`].
576///
577/// Operators with multi-tenant deployments may want a tighter per-instance
578/// linear-memory cap than the workspace-wide
579/// [`HARD_MAX_LINEAR_MEMORY_BYTES`] ceiling (4 GiB, matching the wasm32 spec
580/// maximum). This config exposes that knob without changing the absolute
581/// ceiling — `new_memory` always enforces `min(config.max_linear_memory_bytes,
582/// HARD_MAX_LINEAR_MEMORY_BYTES)`, so the const remains a hard upper bound
583/// that no configured value can exceed.
584#[derive(Debug, Clone)]
585pub struct MemoryCreatorConfig {
586    /// Maximum bytes any single Wasm linear memory may be backed by. Capped
587    /// by [`HARD_MAX_LINEAR_MEMORY_BYTES`]; values above the const are
588    /// silently clamped to the const. Defaults to [`HARD_MAX_LINEAR_MEMORY_BYTES`]
589    /// so default behaviour is unchanged.
590    pub max_linear_memory_bytes: usize,
591}
592
593impl Default for MemoryCreatorConfig {
594    fn default() -> Self {
595        Self {
596            max_linear_memory_bytes: HARD_MAX_LINEAR_MEMORY_BYTES,
597        }
598    }
599}
600
601impl MemoryCreatorConfig {
602    /// The effective per-instance cap: the smaller of the configured value
603    /// and the workspace's [`HARD_MAX_LINEAR_MEMORY_BYTES`] ceiling.
604    pub fn effective_max_linear_memory_bytes(&self) -> usize {
605        self.max_linear_memory_bytes
606            .min(HARD_MAX_LINEAR_MEMORY_BYTES)
607    }
608}
609
610/// A [`MemoryCreator`] that hands out [`TensorWasmLinearMemory`] instances.
611///
612/// Wrap in [`Arc`] and pass to `wasmtime::Config::with_host_memory`.
613#[derive(Clone)]
614pub struct TensorWasmMemoryCreator {
615    inner: Arc<MemoryCreatorState>,
616}
617
618struct MemoryCreatorState {
619    device_id: DeviceId,
620    /// Optional pre-allocated slab. When set, `new_memory` carves Wasm linear
621    /// memories from this pool via [`UnifiedMemoryPool::allocate`]; if the
622    /// slab is exhausted it falls back to a fresh [`UnifiedBuffer`].
623    pool: Option<Arc<UnifiedMemoryPool>>,
624    /// Tunable knobs (per-instance cap, etc.). See [`MemoryCreatorConfig`].
625    config: MemoryCreatorConfig,
626    /// Tenant context for GPU memory accounting (roadmap feature #8).
627    /// Set via [`TensorWasmMemoryCreator::with_tenant_context`]; when
628    /// present, every fresh [`UnifiedBuffer`] allocation (the fallback
629    /// path when the pool is exhausted, or the no-pool path) routes
630    /// through
631    /// [`UnifiedBuffer::new_on_with_tenant_context`], which calls
632    /// `consume_gpu_bytes` before allocating and `release_gpu_bytes`
633    /// on Drop.
634    ///
635    /// **Pool path is intentionally unmetered.** Pool-carved memories
636    /// share one slab allocation that was already paid for at pool
637    /// construction; counting each carve against the cap would
638    /// double-count the slab. The pool teardown documented on
639    /// `UnifiedMemoryPool` already enforces the all-or-nothing
640    /// lifecycle. A future GPU-quota refinement may apportion the slab
641    /// across tenants at pool-construction time; that is tracked in
642    /// `docs/GPU-QUOTAS.md` "v0.4 follow-up".
643    tenant_ctx: Option<Arc<tensor_wasm_tenant::TenantContext>>,
644}
645
646impl Default for TensorWasmMemoryCreator {
647    fn default() -> Self {
648        Self::new(DeviceId::default())
649    }
650}
651
652impl TensorWasmMemoryCreator {
653    /// Construct without an underlying pool. New memories allocate fresh
654    /// [`UnifiedBuffer`]s on every `new_memory` call. Uses the default
655    /// [`MemoryCreatorConfig`] (per-instance cap =
656    /// [`HARD_MAX_LINEAR_MEMORY_BYTES`]).
657    pub fn new(device_id: DeviceId) -> Self {
658        Self::with_config(device_id, MemoryCreatorConfig::default())
659    }
660
661    /// Construct without an underlying pool, with a custom
662    /// [`MemoryCreatorConfig`] (e.g. a tighter per-instance cap for
663    /// multi-tenant deployments). The configured cap is clamped to
664    /// [`HARD_MAX_LINEAR_MEMORY_BYTES`] — see
665    /// [`MemoryCreatorConfig::effective_max_linear_memory_bytes`].
666    pub fn with_config(device_id: DeviceId, config: MemoryCreatorConfig) -> Self {
667        Self {
668            inner: Arc::new(MemoryCreatorState {
669                device_id,
670                pool: None,
671                config,
672                tenant_ctx: None,
673            }),
674        }
675    }
676
677    /// Construct a tenant-aware creator that accounts every fresh
678    /// [`UnifiedBuffer`] against the tenant's GPU memory cap.
679    ///
680    /// Roadmap feature #8 builder: holds an `Arc<TenantContext>` and
681    /// routes the no-pool / pool-fallback allocation paths through
682    /// [`UnifiedBuffer::new_on_with_tenant_context`]. On a cap
683    /// violation the underlying error is
684    /// [`tensor_wasm_core::error::TensorWasmError::GpuMemoryExhausted`];
685    /// it surfaces here as a `String` because Wasmtime's
686    /// `MemoryCreator::new_memory` returns `Result<_, String>`.
687    ///
688    /// **Pool-carved memories are intentionally unmetered** — see the
689    /// note on `MemoryCreatorState::tenant_ctx` for the
690    /// rationale and the v0.4 follow-up.
691    pub fn with_tenant_context(
692        device_id: DeviceId,
693        tenant_ctx: Arc<tensor_wasm_tenant::TenantContext>,
694    ) -> Self {
695        Self {
696            inner: Arc::new(MemoryCreatorState {
697                device_id,
698                pool: None,
699                config: MemoryCreatorConfig::default(),
700                tenant_ctx: Some(tenant_ctx),
701            }),
702        }
703    }
704
705    /// Tenant-aware variant of [`Self::with_pool`].
706    ///
707    /// Behaves like [`Self::with_pool`] for the pool-carve hot path
708    /// (no extra counter traffic) and like
709    /// [`Self::with_tenant_context`] for the fallback fresh-allocation
710    /// path.
711    pub fn with_pool_and_tenant_context(
712        device_id: DeviceId,
713        pool: Arc<UnifiedMemoryPool>,
714        tenant_ctx: Arc<tensor_wasm_tenant::TenantContext>,
715    ) -> Self {
716        Self {
717            inner: Arc::new(MemoryCreatorState {
718                device_id,
719                pool: Some(pool),
720                config: MemoryCreatorConfig::default(),
721                tenant_ctx: Some(tenant_ctx),
722            }),
723        }
724    }
725
726    /// Construct with a pre-allocated pool. `new_memory` carves out of this
727    /// slab; on exhaustion it falls back to a fresh [`UnifiedBuffer`] so a
728    /// short slab does not turn into a fatal error.
729    ///
730    /// **Lifetime contract.** Pool-backed linear memories form an
731    /// all-or-nothing batch: the caller must drop every Wasm instance handed
732    /// out by this creator *before* dropping the last `Arc<UnifiedMemoryPool>`
733    /// reference and calling [`UnifiedMemoryPool::reset`]. See `new_memory`
734    /// for the `unsafe` rationale.
735    pub fn with_pool(device_id: DeviceId, pool: Arc<UnifiedMemoryPool>) -> Self {
736        Self::with_pool_and_config(device_id, pool, MemoryCreatorConfig::default())
737    }
738
739    /// Construct with both a pre-allocated pool and a custom
740    /// [`MemoryCreatorConfig`].
741    pub fn with_pool_and_config(
742        device_id: DeviceId,
743        pool: Arc<UnifiedMemoryPool>,
744        config: MemoryCreatorConfig,
745    ) -> Self {
746        Self {
747            inner: Arc::new(MemoryCreatorState {
748                device_id,
749                pool: Some(pool),
750                config,
751                tenant_ctx: None,
752            }),
753        }
754    }
755
756    /// The device this creator targets.
757    pub fn device_id(&self) -> DeviceId {
758        self.inner.device_id
759    }
760
761    /// The underlying pool, if one was provided at construction.
762    pub fn pool(&self) -> Option<&Arc<UnifiedMemoryPool>> {
763        self.inner.pool.as_ref()
764    }
765
766    /// The tunable configuration handed to this creator.
767    pub fn config(&self) -> &MemoryCreatorConfig {
768        &self.inner.config
769    }
770}
771
772unsafe impl MemoryCreator for TensorWasmMemoryCreator {
773    fn new_memory(
774        &self,
775        _ty: MemoryType,
776        minimum: usize,
777        maximum: Option<usize>,
778        reserved_size_in_bytes: Option<usize>,
779        guard_size_in_bytes: usize,
780    ) -> Result<Box<dyn LinearMemory>, String> {
781        let max = effective_max_bytes(maximum);
782        // Enforce HARD_MAX_LINEAR_MEMORY_BYTES on the cap declared by the
783        // module's MemoryType BEFORE any backing allocation runs. Wasmtime's
784        // ResourceLimiter only fires on `memory.grow`, not on initial
785        // allocation, so without this guard a guest declaring
786        // `(memory 1 65536)` would force a 4 GiB allocation here. See
787        // [`HARD_MAX_LINEAR_MEMORY_BYTES`] for the closed mem-H5 /
788        // exec-S-2 / exec-S-10 audit gap.
789        if max > HARD_MAX_LINEAR_MEMORY_BYTES {
790            return Err(format!(
791                "module-declared memory maximum {max} bytes exceeds hard cap {HARD_MAX_LINEAR_MEMORY_BYTES} bytes",
792            ));
793        }
794        if minimum > HARD_MAX_LINEAR_MEMORY_BYTES {
795            return Err(format!(
796                "module-declared memory minimum {minimum} bytes exceeds hard cap {HARD_MAX_LINEAR_MEMORY_BYTES} bytes",
797            ));
798        }
799        if minimum > max {
800            return Err(format!("minimum {minimum} > maximum {max}"));
801        }
802
803        // We do not allocate OS guard pages for `TensorWasmLinearMemory`: it is
804        // backed by `UnifiedBuffer` (managed memory on CUDA hosts), and the
805        // CUDA driver migrates managed pages between host and device — host
806        // `mprotect`/`VirtualProtect` would race the migration machinery. We
807        // therefore cannot honour a non-zero `guard_size_in_bytes` and must
808        // reject the configuration outright rather than silently degrade
809        // Wasmtime's expectations of OOB-trapping behaviour. Callers wanting
810        // page-level guards should disable the pooling/guard knobs in
811        // `wasmtime::Config` (see `crates/tensor-wasm-mem/tests/isolation.rs`) or
812        // switch the engine to the `PoolingMpk` backend.
813        if guard_size_in_bytes > 0 {
814            return Err(format!(
815                "TensorWasmMemoryCreator cannot honour guard_size_in_bytes = {guard_size_in_bytes}: \
816                 managed-memory backings are incompatible with host page guards. \
817                 Set `Config::memory_guard_size(0)` and \
818                 `Config::guard_before_linear_memory(false)`, or use the PoolingMpk backend."
819            ));
820        }
821        // A `reserved_size_in_bytes` request larger than what we are about to
822        // allocate cannot be satisfied — we always allocate exactly the cap.
823        if let Some(reserved) = reserved_size_in_bytes {
824            if reserved > max {
825                return Err(format!(
826                    "TensorWasmMemoryCreator cannot reserve {reserved} bytes: backing capacity is {max}"
827                ));
828            }
829        }
830
831        if let Some(pool) = self.inner.pool.as_ref() {
832            // Wasm linear memory is page-aligned by spec (64 KiB pages).
833            const WASM_PAGE: usize = 65_536;
834            let carve_size = max.max(1);
835            match pool.allocate(carve_size, WASM_PAGE) {
836                Ok(mut alloc) => {
837                    // Capture `(offset, size)` BEFORE we `mem::forget` the
838                    // PoolAllocation drop guard — these figures get handed to
839                    // `pool.release(...)` from `PooledLinearMemory::Drop` so
840                    // the pool's `live` counter walks back down on teardown
841                    // (audit T6). The bump pointer is not rewound by release;
842                    // monotonic-bump semantics are preserved.
843                    let pool_offset = alloc.offset();
844                    let slice = alloc.as_mut_slice();
845                    let base_ptr = slice.as_mut_ptr();
846                    let size = slice.len();
847                    // SAFETY: We leak the PoolAllocation drop guard intentionally.
848                    // The pool uses "batch reclaim" semantics (reset() succeeds
849                    // only when live count is zero) and pool-backed linear
850                    // memories are torn down together when the parent
851                    // TensorWasmMemoryCreator's Arc<UnifiedMemoryPool> drops.
852                    //
853                    // Before audit T6, the leak was used to keep the live
854                    // counter permanently elevated as a misuse signal —
855                    // dropping the pool-backed memory had no effect on the
856                    // counter and `reset()` was therefore permanently blocked
857                    // once any pool-backed memory had been issued. That
858                    // blocked the legitimate "drop every issued memory then
859                    // reset" workflow operators expect for per-tenant slab
860                    // reuse.
861                    //
862                    // T6 fix: `PooledLinearMemory::Drop` now calls
863                    // `pool.release(pool_offset, size)` to mirror the
864                    // forgotten `PoolAllocation::Drop` — same effect on the
865                    // `live` counter (decrement), no effect on the bump
866                    // pointer (no rewind). The `mem::forget(alloc)` still
867                    // matters because the borrow-checker view of
868                    // `PoolAllocation` would otherwise tie us to a lifetime
869                    // `'p` over `&'p UnifiedMemoryPool`, which we cannot give
870                    // an owned `Arc`-keyed `LinearMemory`.
871                    //
872                    // The raw `base_ptr` remains valid because `pool_keepalive`
873                    // holds the slab alive for the lifetime of the returned
874                    // `PooledLinearMemory`, and the bump allocator never hands
875                    // the same byte range to another allocation until an
876                    // explicit `reset()` rewinds the bump pointer — which
877                    // requires `&mut UnifiedMemoryPool`, impossible while any
878                    // `Arc<UnifiedMemoryPool>` clone (including this one)
879                    // exists.
880                    //
881                    // # Teardown contract (audit T6)
882                    //
883                    // Pool slabs that served a [`PooledLinearMemory`] become
884                    // reset-eligible once every issued memory has been
885                    // dropped: dropping a `PooledLinearMemory` decrements the
886                    // pool's `live` counter (without rewinding the bump
887                    // pointer), and once `live` reaches zero AND every
888                    // `Arc<UnifiedMemoryPool>` clone except the operator's
889                    // has been dropped, `Arc::get_mut(&mut pool).reset()`
890                    // succeeds. See `tests/pool_teardown_contract.rs` for the
891                    // pinned behaviour.
892                    std::mem::forget(alloc);
893                    return Ok(Box::new(PooledLinearMemory {
894                        pool_keepalive: Arc::clone(pool),
895                        pool_offset,
896                        base_ptr,
897                        size,
898                        current_size: minimum,
899                        max_size: size,
900                    }));
901                }
902                Err(e) => {
903                    tracing::debug!(
904                        error = %e,
905                        requested = carve_size,
906                        remaining = pool.remaining(),
907                        "pool exhausted; falling back to fresh UnifiedBuffer"
908                    );
909                    // Pool/creator device-id mismatch is a configuration smell:
910                    // the fallback `UnifiedBuffer` will be anchored to the
911                    // creator's device, not the pool's, so memory locality
912                    // assumptions baked into the pool layout no longer hold.
913                    // Surface as a warning rather than an error so the
914                    // fallback still serves the allocation.
915                    let pool_device = pool.device_id();
916                    if pool_device != self.inner.device_id {
917                        tracing::warn!(
918                            pool_device_id = %pool_device,
919                            creator_device_id = %self.inner.device_id,
920                            "fallback UnifiedBuffer will use creator's device, \
921                             which differs from the exhausted pool's device"
922                        );
923                    }
924                }
925            }
926        }
927
928        // Tenant-aware fresh allocation when a `TenantContext` was wired
929        // in (roadmap feature #8). The Wasmtime `MemoryCreator` API
930        // returns `Result<_, String>` so the structured
931        // `GpuMemoryExhausted { requested, limit, current }` collapses
932        // to a string here — non-Wasmtime callers that want the
933        // structured form should reach for
934        // `TensorWasmLinearMemory::new_on_with_tenant_context` directly.
935        let mem = if let Some(tenant_ctx) = self.inner.tenant_ctx.as_ref() {
936            TensorWasmLinearMemory::new_on_with_tenant_context(
937                minimum,
938                maximum,
939                self.inner.device_id,
940                Arc::clone(tenant_ctx),
941            )
942            .map_err(|e| e.to_string())?
943        } else {
944            TensorWasmLinearMemory::new_on(minimum, maximum, self.inner.device_id)
945                .map_err(|e| e.to_string())?
946        };
947        Ok(Box::new(mem))
948    }
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954
955    #[test]
956    fn construct_and_query_size() {
957        let mem = TensorWasmLinearMemory::new(64 * 1024, Some(1024 * 1024)).unwrap();
958        assert_eq!(mem.byte_size(), 64 * 1024);
959        assert_eq!(mem.byte_capacity(), 1024 * 1024);
960        assert_eq!(mem.capacity(), 1024 * 1024);
961    }
962
963    #[test]
964    fn grow_increases_visible_size() {
965        let mut mem = TensorWasmLinearMemory::new(64 * 1024, Some(256 * 1024)).unwrap();
966        mem.grow_to(128 * 1024).expect("grow");
967        assert_eq!(mem.byte_size(), 128 * 1024);
968    }
969
970    #[test]
971    fn grow_beyond_maximum_rejected() {
972        let mut mem = TensorWasmLinearMemory::new(64 * 1024, Some(128 * 1024)).unwrap();
973        let err = mem.grow_to(256 * 1024).unwrap_err();
974        assert!(err.to_string().contains("maximum"));
975    }
976
977    #[test]
978    fn shrink_rejected() {
979        let mut mem = TensorWasmLinearMemory::new(128 * 1024, Some(1024 * 1024)).unwrap();
980        let err = mem.grow_to(64 * 1024).unwrap_err();
981        assert!(err.to_string().contains("shrink"));
982    }
983
984    #[test]
985    fn ptr_stable_across_grow() {
986        let mut mem = TensorWasmLinearMemory::new(64 * 1024, Some(1024 * 1024)).unwrap();
987        let p1 = mem.as_ptr();
988        mem.grow_to(256 * 1024).unwrap();
989        let p2 = mem.as_ptr();
990        assert_eq!(p1, p2, "as_ptr must be stable across grow_to");
991    }
992
993    #[test]
994    fn wasm_accessible_covers_capacity() {
995        let mem = TensorWasmLinearMemory::new(64 * 1024, Some(1024 * 1024)).unwrap();
996        let r = mem.wasm_accessible();
997        assert_eq!(r.end - r.start, 1024 * 1024);
998    }
999
1000    #[test]
1001    fn minimum_exceeds_maximum_rejected() {
1002        let err = TensorWasmLinearMemory::new(1024 * 1024, Some(512 * 1024)).unwrap_err();
1003        assert!(matches!(err, UnifiedError::Allocation(_)));
1004    }
1005
1006    #[test]
1007    fn maximum_above_hard_cap_rejected_without_allocating() {
1008        // 5 GiB > HARD_MAX_LINEAR_MEMORY_BYTES (4 GiB). Must fast-fail with
1009        // TooLarge BEFORE any backing allocation is attempted. The point is
1010        // to make sure a guest can't push us into a multi-GiB cudaMalloc
1011        // simply by declaring a giant maximum on its `(memory ...)`.
1012        let big = HARD_MAX_LINEAR_MEMORY_BYTES + 1;
1013        let err = TensorWasmLinearMemory::new(64 * 1024, Some(big)).unwrap_err();
1014        match err {
1015            UnifiedError::TooLarge { requested, limit } => {
1016                assert_eq!(requested, big as u64);
1017                assert_eq!(limit, HARD_MAX_LINEAR_MEMORY_BYTES as u64);
1018            }
1019            other => panic!("expected TooLarge, got {other:?}"),
1020        }
1021    }
1022
1023    #[test]
1024    fn minimum_above_hard_cap_rejected() {
1025        // A pathological module declaring an initial size above the hard
1026        // cap is rejected at the same gate, even if the maximum is None
1027        // (which would otherwise default to DEFAULT_MAX_BYTES, < cap).
1028        let big = HARD_MAX_LINEAR_MEMORY_BYTES + 1;
1029        let err = TensorWasmLinearMemory::new(big, Some(big + 1)).unwrap_err();
1030        assert!(matches!(err, UnifiedError::TooLarge { .. }));
1031    }
1032
1033    #[test]
1034    fn maximum_exactly_at_hard_cap_admitted_but_not_allocated_in_test() {
1035        // We can't actually attempt the 4 GiB allocation here (CI hosts
1036        // don't have that much UVM / heap), so we only assert that the
1037        // CHECK at the cap boundary lets the request through and produces
1038        // an `Allocation` failure mode from the underlying allocator
1039        // rather than the early `TooLarge` rejection. The downstream
1040        // allocator may succeed on a beefy host; either outcome is fine
1041        // for this contract test.
1042        let at_cap = HARD_MAX_LINEAR_MEMORY_BYTES;
1043        // Use a tiny minimum so we exercise the cap check on `max` only.
1044        let result = TensorWasmLinearMemory::new(0, Some(at_cap));
1045        match result {
1046            Ok(_) => { /* well-resourced host */ }
1047            Err(UnifiedError::Allocation(_)) | Err(UnifiedError::Cuda(_)) => {
1048                // Allocator refused the 4 GiB request — fine.
1049            }
1050            Err(other) => panic!(
1051                "request at exactly the hard cap must not be rejected as TooLarge: got {other:?}"
1052            ),
1053        }
1054    }
1055
1056    #[test]
1057    fn creator_rejects_module_max_above_hard_cap() {
1058        use wasmtime::MemoryCreator;
1059        let creator = TensorWasmMemoryCreator::default();
1060        let mt = wasmtime::MemoryType::new(1, None);
1061        let big = HARD_MAX_LINEAR_MEMORY_BYTES + 1;
1062        // `new_memory` returns `Result<Box<dyn LinearMemory>, String>`. The Ok
1063        // variant is not Debug (`dyn LinearMemory` doesn't derive Debug), so
1064        // `expect_err` won't compile; pattern-match instead.
1065        let err = match creator.new_memory(mt, 64 * 1024, Some(big), None, 0) {
1066            Ok(_) => panic!("oversized module max must be refused"),
1067            Err(e) => e,
1068        };
1069        assert!(
1070            err.contains("hard cap"),
1071            "error must mention the hard cap; got: {err}"
1072        );
1073    }
1074
1075    #[test]
1076    fn creator_default_device_zero() {
1077        let c = TensorWasmMemoryCreator::default();
1078        assert_eq!(c.device_id(), DeviceId(0));
1079    }
1080
1081    #[test]
1082    fn creator_default_pool_is_none() {
1083        let c = TensorWasmMemoryCreator::default();
1084        assert!(c.pool().is_none());
1085    }
1086
1087    #[test]
1088    fn creator_with_pool_round_trip() {
1089        let pool = std::sync::Arc::new(crate::pool::UnifiedMemoryPool::new(64 * 1024).unwrap());
1090        let creator = TensorWasmMemoryCreator::with_pool(DeviceId(2), pool.clone());
1091        assert_eq!(creator.device_id(), DeviceId(2));
1092        assert!(creator.pool().is_some());
1093        assert!(std::sync::Arc::ptr_eq(creator.pool().unwrap(), &pool));
1094    }
1095
1096    #[test]
1097    fn as_slice_reflects_written_bytes_and_current_size() {
1098        // Grow into the pre-allocated cap, scribble through the LinearMemory
1099        // `as_ptr()` contract that Wasmtime would use, then read back via the
1100        // crate-private `as_slice()` host-inspection path. This covers the
1101        // intended use: post-execution snapshot/diagnostic reads between guest
1102        // invocations (see the safety contract on `as_slice`).
1103        let mut mem = TensorWasmLinearMemory::new(64 * 1024, Some(256 * 1024)).unwrap();
1104        mem.grow_to(128 * 1024).expect("grow");
1105        // SAFETY: no guest is executing; we hold `&mut mem` exclusively, so
1106        // writing through `as_ptr()` cannot race the Wasmtime borrow tracker.
1107        unsafe {
1108            let p = mem.as_ptr();
1109            *p.add(0) = 0xDE;
1110            *p.add(1) = 0xAD;
1111            *p.add(64 * 1024) = 0xBE;
1112            *p.add(128 * 1024 - 1) = 0xEF;
1113        }
1114        let s = mem.as_slice();
1115        assert_eq!(
1116            s.len(),
1117            128 * 1024,
1118            "slice tracks current_size, not capacity"
1119        );
1120        assert_eq!(s[0], 0xDE);
1121        assert_eq!(s[1], 0xAD);
1122        assert_eq!(s[64 * 1024], 0xBE);
1123        assert_eq!(s[128 * 1024 - 1], 0xEF);
1124    }
1125
1126    #[test]
1127    fn is_uvm_backed_matches_feature_flag() {
1128        // Closes the v0.3.2 audit gap (Problem #5). With EITHER `--features
1129        // unified-memory` (cust path) OR `--features cudarc-backend` (the
1130        // W1.2 spike now wired in as a third `UnifiedBuffer` Backing
1131        // variant, see `crate::unified` precedence table), the wasm linear
1132        // memory's backing IS `cuMemAllocManaged` and the host pointer
1133        // doubles as a device pointer for kernel args. Without either
1134        // feature, the backing is a heap `Box<[u8]>`. Either way the probe
1135        // must reflect build configuration — never lie.
1136        let mem = TensorWasmLinearMemory::new(64 * 1024, Some(1024 * 1024)).unwrap();
1137        #[cfg(feature = "unified-memory")]
1138        assert!(
1139            mem.is_uvm_backed(),
1140            "with --features unified-memory the wasm linear memory MUST be UVM-backed"
1141        );
1142        #[cfg(all(not(feature = "unified-memory"), feature = "cudarc-backend"))]
1143        assert!(
1144            mem.is_uvm_backed(),
1145            "with --features cudarc-backend the wasm linear memory MUST be UVM-backed"
1146        );
1147        #[cfg(all(not(feature = "unified-memory"), not(feature = "cudarc-backend")))]
1148        assert!(
1149            !mem.is_uvm_backed(),
1150            "without any CUDA backing feature the linear memory must be the heap Box<[u8]>"
1151        );
1152    }
1153
1154    #[test]
1155    fn as_ptr_returns_non_null_inside_backing_region() {
1156        // The kernel-args pipeline (W1.1, see
1157        // `crates/tensor-wasm-wasi-gpu/src/kernel_args.rs`) translates a
1158        // guest pointer to a host pointer via `as_ptr() + guest_offset`.
1159        // Under --features unified-memory that host pointer doubles as a
1160        // device pointer, so two properties must hold for every backing:
1161        // (1) `as_ptr()` is non-null; (2) the returned pointer lies inside
1162        // the buffer's `wasm_accessible()` region, i.e. the byte at
1163        // offset 0 is reachable from a kernel via the same address.
1164        let mem = TensorWasmLinearMemory::new(64 * 1024, Some(1024 * 1024)).unwrap();
1165        let p = LinearMemory::as_ptr(&mem);
1166        assert!(!p.is_null(), "as_ptr must be non-null");
1167        let r = mem.wasm_accessible();
1168        let p_addr = p as usize;
1169        assert!(
1170            r.contains(&p_addr),
1171            "as_ptr ({p_addr:#x}) must land inside wasm_accessible ({:#x}..{:#x})",
1172            r.start,
1173            r.end,
1174        );
1175    }
1176
1177    #[test]
1178    fn grow_up_to_preallocated_cap_succeeds_beyond_fails() {
1179        // Memory-growth semantics for the UVM path: pre-allocate at the
1180        // requested maximum and treat grow_to as a logical-size bump up
1181        // to that cap (option (a) in the task brief). This test pins
1182        // both halves of that contract: grow up to the cap succeeds in
1183        // a single step; one byte beyond the cap fails. It runs on both
1184        // the heap and the UVM backing because the contract is the same
1185        // for both; only the underlying allocator differs.
1186        const MAX: usize = 256 * 1024;
1187        let mut mem = TensorWasmLinearMemory::new(64 * 1024, Some(MAX)).unwrap();
1188        // Step 1: grow exactly to the cap — must succeed.
1189        mem.grow_to(MAX).expect("grow up to cap must succeed");
1190        assert_eq!(mem.byte_size(), MAX);
1191        // Step 2: anything past the cap is rejected; the pre-allocated
1192        // region cannot be resized in place under `cuMemAllocManaged`.
1193        let err = mem.grow_to(MAX + 1).expect_err("grow past cap must fail");
1194        assert!(err.to_string().contains("maximum"));
1195    }
1196
1197    #[test]
1198    fn grow_to_zero_fills_newly_exposed_bytes() {
1199        // Cross-tenant data-leak regression test (audit H2):
1200        // -------------------------------------------------------------------
1201        // The wasm spec requires bytes newly exposed by `memory.grow` to read
1202        // as zero. Because `TensorWasmLinearMemory` pre-allocates the entire
1203        // `maximum_size` up front and only bumps a `current_size`, the host
1204        // backing already contains *whatever was previously written* in the
1205        // `[current_size, max)` window. This test scribbles 0xCD into a band
1206        // that sits in the pre-allocated tail (past `current_size`), then
1207        // grows past that band and asserts every newly-exposed byte reads
1208        // zero — including the previously-poisoned range.
1209        const MIN: usize = 64 * 1024;
1210        const MAX: usize = 4 * 64 * 1024; // four wasm pages
1211        const POISON_START: usize = MIN + 4096;
1212        const POISON_END: usize = MIN + 8192;
1213        const GROW_TO: usize = MAX;
1214
1215        let mut mem = TensorWasmLinearMemory::new(MIN, Some(MAX)).unwrap();
1216
1217        // Poison a band sitting in the pre-allocated tail. This simulates a
1218        // stale write left over from a previous tenant or a host helper.
1219        // SAFETY: we hold `&mut mem` exclusively and no guest is executing.
1220        // The buffer's physical extent covers `MAX` bytes even though
1221        // `current_size == MIN`, so writing into the tail is in-bounds.
1222        unsafe {
1223            let p = LinearMemory::as_ptr(&mem);
1224            for off in POISON_START..POISON_END {
1225                *p.add(off) = 0xCD;
1226            }
1227        }
1228
1229        // Grow past the poisoned region.
1230        mem.grow_to(GROW_TO).expect("grow must succeed");
1231        assert_eq!(mem.byte_size(), GROW_TO);
1232
1233        // Every byte in `[MIN, GROW_TO)` — the range newly exposed to the
1234        // guest by `grow_to` — must read as zero. If the zero-fill is
1235        // missing, the 0xCD band leaks straight through.
1236        let s = mem.as_slice();
1237        assert_eq!(s.len(), GROW_TO);
1238        let leaked: Vec<usize> = s[MIN..GROW_TO]
1239            .iter()
1240            .enumerate()
1241            .filter_map(|(i, &v)| if v != 0 { Some(MIN + i) } else { None })
1242            .collect();
1243        assert!(
1244            leaked.is_empty(),
1245            "grow_to leaked {} non-zero bytes in newly-exposed range \
1246             [{MIN}, {GROW_TO}); first leak at offset {:?} (value would be 0xCD if poison)",
1247            leaked.len(),
1248            leaked.first(),
1249        );
1250    }
1251
1252    #[test]
1253    fn pooled_grow_to_zero_fills_newly_exposed_bytes() {
1254        // Mirror of `grow_to_zero_fills_newly_exposed_bytes` for the
1255        // pool-backed linear memory path (`PooledLinearMemory`). The
1256        // exposure surface is the same — `grow_to` only bumps a logical
1257        // size against a larger physical reservation — so the zero-fill
1258        // discipline must match.
1259        use std::sync::Arc;
1260        const MIN: usize = 64 * 1024;
1261        const MAX: usize = 4 * 64 * 1024;
1262        const POISON_START: usize = MIN + 4096;
1263        const POISON_END: usize = MIN + 8192;
1264        const GROW_TO: usize = MAX;
1265
1266        let pool = Arc::new(crate::pool::UnifiedMemoryPool::new(8 * 1024 * 1024).unwrap());
1267        let creator = TensorWasmMemoryCreator::with_pool(DeviceId::default(), pool.clone());
1268        let mt = wasmtime::MemoryType::new(1, Some(4));
1269        use wasmtime::MemoryCreator;
1270        let mut mem = creator
1271            .new_memory(mt, MIN, Some(MAX), None, 0)
1272            .expect("new_memory");
1273
1274        // Poison the pre-allocated tail.
1275        // SAFETY: same rationale as the non-pooled test above; we hold
1276        // exclusive `&mut mem` and the physical region is `MAX` bytes.
1277        unsafe {
1278            let p = mem.as_ptr();
1279            for off in POISON_START..POISON_END {
1280                *p.add(off) = 0xCD;
1281            }
1282        }
1283
1284        mem.grow_to(GROW_TO).expect("grow must succeed");
1285        assert_eq!(mem.byte_size(), GROW_TO);
1286
1287        // Read back the entire region via `as_ptr` + raw slice (the pooled
1288        // path does not expose a private `as_slice`; that's only on
1289        // `TensorWasmLinearMemory`).
1290        // SAFETY: no guest is executing; `as_ptr()` points to `GROW_TO`
1291        // valid bytes inside the carved slab.
1292        let s = unsafe { std::slice::from_raw_parts(mem.as_ptr() as *const u8, GROW_TO) };
1293        let leaked: Vec<usize> = s[MIN..GROW_TO]
1294            .iter()
1295            .enumerate()
1296            .filter_map(|(i, &v)| if v != 0 { Some(MIN + i) } else { None })
1297            .collect();
1298        assert!(
1299            leaked.is_empty(),
1300            "pooled grow_to leaked {} non-zero bytes in [{MIN}, {GROW_TO}); first at {:?}",
1301            leaked.len(),
1302            leaked.first(),
1303        );
1304    }
1305
1306    #[test]
1307    fn creator_with_pool_carves_from_slab() {
1308        use std::sync::Arc;
1309        let pool = Arc::new(crate::pool::UnifiedMemoryPool::new(8 * 1024 * 1024).unwrap());
1310        let creator = TensorWasmMemoryCreator::with_pool(DeviceId::default(), pool.clone());
1311        let mt = wasmtime::MemoryType::new(1, Some(2));
1312        use wasmtime::MemoryCreator;
1313        let mem = creator
1314            .new_memory(mt, 64 * 1024, Some(128 * 1024), None, 0)
1315            .expect("new_memory");
1316        assert!(mem.byte_size() == 64 * 1024);
1317        assert!(mem.byte_capacity() == 128 * 1024);
1318        // Verify the pool's live count incremented (proving the carving path ran)
1319        assert_eq!(pool.live_allocations(), 1);
1320        // After audit T6: dropping `mem` decrements `live` back to 0 (the
1321        // `PooledLinearMemory::Drop` impl calls `pool.release`). The bump
1322        // pointer stays put — only `reset()` rewinds. See
1323        // `tests/pool_teardown_contract.rs` for the end-to-end pinned
1324        // behaviour.
1325        drop(mem);
1326        assert_eq!(pool.live_allocations(), 0);
1327    }
1328}