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}