tensor_wasm_wasi_gpu/host.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Host-side implementations of the `wasi:cuda` functions registered with
5//! `wasmtime::Linker`.
6//!
7//! On non-CUDA hosts every function returns [`AbiError::NotAvailable`] via
8//! its wire-format negative i32 code. The Wasm side cannot tell whether
9//! that's because the host lacks CUDA or because the runtime explicitly
10//! declined a call — both are appropriate "kernel did not run" signals.
11//!
12//! When the `cuda` feature is enabled (S16+ on real hardware) the bodies
13//! switch to real CUDA dispatch via the `cust` crate.
14//!
15//! ## Explicit device-memory surface
16//!
17//! Alongside `load_ptx` / `launch` / `sync`, this module wires the explicit
18//! device-buffer functions `alloc` / `free` / `memcpy_h2d` / `memcpy_d2h`.
19//! Where `launch`'s pointer arguments rely on CUDA Unified Memory (a guest
20//! offset doubling as a device address), the device-buffer surface lets a
21//! guest manage discrete device allocations that work on any CUDA host.
22//! Handles are owner-scoped in a per-instance [`crate::device_mem::DeviceMemRegistry`]
23//! with an aggregate-bytes cap, mirroring the kernel registry — a guest
24//! cannot forge another instance's handle. On no-CUDA hosts the bodies
25//! validate their arguments (bounds-checking guest pointers, rejecting
26//! oversize / zero requests) and return [`AbiError::NotAvailable`] like the
27//! `launch` stub; the `#[cfg(feature = "cuda")]` `cuMemAlloc` / `cuMemcpy*`
28//! paths are gated and UNVERIFIED-PENDING-HARDWARE.
29//!
30//! NOTE: Cuda-feature code paths in this file are compile-tested on CUDA
31//! hosts only; on no-CUDA hosts only the `#[cfg(not(feature = "cuda"))]`
32//! branches are exercised. The cuda branches must be kept consistent with
33//! the cust 0.3.x API.
34//!
35//! ## Launch dimension caps
36//!
37//! Every launch is validated against CUDA hardware ceilings before any
38//! driver call:
39//! - block dimensions must each be in `[1, MAX_BLOCK_DIM]` and the product
40//! `block_x * block_y * block_z` must be at most [`MAX_THREADS_PER_BLOCK`]
41//! (1024 across all current GPUs).
42//! - grid dimensions must each be in `[1, MAX_GRID_DIM]`
43//! (2^31 - 1, the CUDA driver maximum for `grid_x`).
44//! - `shared_mem` must be in `[0, MAX_DYNAMIC_SHARED_MEM_BYTES]` — a
45//! conservative host cap rejected before the driver call so an
46//! obviously-oversize request fails actionably host-side rather than as
47//! a generic `CUDA_ERROR_INVALID_VALUE`.
48//!
49//! Violations return [`AbiError::InvalidDimensions`] without ever calling
50//! into `cuLaunchKernel` — the failure is reported with a structured
51//! `last_error` describing which axis tripped the cap.
52//!
53//! ## Kernel argument marshalling (v0.2)
54//!
55//! The launch host function takes `(args_ptr, args_len)` describing a
56//! byte buffer in the guest's linear memory. The buffer carries a
57//! sequence of tagged records — see [`crate::kernel_args`] for the wire
58//! format. The launch path bounds-checks the buffer against the caller's
59//! linear memory, then [`crate::kernel_args::parse_argv`] turns it into a
60//! `Vec<LoweredArg>` where pointer arguments have been resolved into raw
61//! host pointers (under CUDA Unified Memory those are also valid device
62//! addresses).
63//!
64//! On CUDA builds the parsed args flow into
65//! [`crate::kernel_args::build_kernel_param_storage`] and then into a
66//! direct `cuLaunchKernel` call — bypassing `cust::launch!` (which would
67//! force statically-typed parameters at the call site). On no-CUDA
68//! builds the parsed args are recorded on the [`WasiCudaContext`] for
69//! testing (see [`WasiCudaContext::last_lowered_args`]) and the launch
70//! returns [`AbiError::NotAvailable`].
71//!
72//! [`AbiError::KernelArgsUnsupported`] is reserved for sanity-cap busts
73//! on otherwise well-formed argv — buffers longer than
74//! [`crate::kernel_args::MAX_KERNEL_ARGS_BYTES`] (4 KiB) or carrying
75//! more than [`crate::kernel_args::MAX_KERNEL_ARGS`] (128) tagged
76//! records. The W1.1 typed-argv lane (live since v0.2.0 of the WIT)
77//! lowers any scalar + pointer argv below those caps into a
78//! `cuLaunchKernel` parameter array. Malformed argv (unknown tag
79//! bytes, truncated records) surfaces as [`AbiError::InvalidArgs`];
80//! out-of-bounds pointer arguments surface as
81//! [`AbiError::InvalidPointer`]. The distinction keeps the error
82//! story crisp for guest debugging.
83
84use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
85use std::sync::Arc;
86use std::sync::Mutex;
87use std::time::Instant;
88
89use tensor_wasm_core::types::{InstanceId, KernelId};
90use tracing::{info, info_span, warn, Instrument};
91use wasmtime::{Caller, Linker};
92
93use crate::abi::{
94 AbiError, FN_ALLOC, FN_FREE, FN_LAST_ERROR_COPY, FN_LAST_ERROR_LEN, FN_LAUNCH, FN_LOAD_PTX,
95 FN_MEMCPY_D2H, FN_MEMCPY_H2D, FN_SYNC, MAX_BLOCK_DIM, MAX_GRID_DIM, MAX_PTX_BYTES,
96 MAX_THREADS_PER_BLOCK, MODULE,
97};
98use crate::async_dispatch::BackPressure;
99use crate::device_mem::{DeviceMemEntry, DeviceMemRegistry, MAX_DEVICE_ALLOC_BYTES};
100use crate::kernel_args::{parse_argv, LoweredArg, LoweredArgSnapshot};
101use crate::registry::{KernelEntry, KernelRegistry};
102use crate::scheduler::SchedulerContext;
103
104/// Maximum byte length of a single recorded `last_error` message.
105///
106/// `record_error` truncates any message above this cap (preserving UTF-8
107/// boundaries and appending an ellipsis) before storing it. The cap defends
108/// against a guest looping `launch` with malformed input — each call would
109/// otherwise force a large `format!` allocation that is immediately
110/// discarded on the next call.
111pub const MAX_RECORDED_ERROR_BYTES: usize = 512;
112
113/// Maximum byte length of a kernel entry-name passed to `load_ptx`.
114///
115/// CUDA identifiers are far below this in practice (PTX entries are
116/// C-style identifiers, typically under 64 bytes). The cap prevents a
117/// guest from forcing a multi-MiB UTF-8 validation + `String::from`
118/// allocation per `load_ptx` call. The PTX-bytes side is already
119/// bounded by [`MAX_PTX_BYTES`]; this is the matching cap for the
120/// entry-name side.
121pub const MAX_ENTRY_NAME_BYTES: usize = 256;
122
123/// Conservative host cap on the dynamic shared-memory bytes a single
124/// `launch` may request (the `shared_mem` argument forwarded to
125/// `cuLaunchKernel` as `shared_mem as u32`).
126///
127/// MEDIUM finding: `validate_launch_args` historically only rejected
128/// `shared_mem < 0`, so any positive value up to `i32::MAX` (~2 GiB) was
129/// forwarded verbatim to the driver. That defers an obviously-bogus
130/// request to `cuLaunchKernel`, which reports it with a far less
131/// actionable `CUDA_ERROR_INVALID_VALUE`. We bound it host-side instead,
132/// mirroring the grid/block-dim posture (reject before any driver call
133/// with [`AbiError::InvalidDimensions`]).
134///
135/// 228 KiB is the current maximum dynamic shared memory per block on
136/// recent NVIDIA architectures (Hopper/Ada opt-in via
137/// `cudaFuncAttributeMaxDynamicSharedMemorySize`). Kernels needing more
138/// do not exist on shipping hardware, so this is a safe upper bound: a
139/// real launch never exceeds it, and a guest that does is caught early.
140pub const MAX_DYNAMIC_SHARED_MEM_BYTES: i32 = 228 * 1024;
141
142/// Per-instance host state passed to wasi-cuda calls.
143///
144/// `WasiCudaContext` is stored in the wasmtime `Store`'s data type (or in a
145/// resource handle thereon). The executor (`tensor-wasm-exec`) creates one per
146/// instance at spawn time.
147pub struct WasiCudaContext {
148 /// Owning instance.
149 pub instance_id: InstanceId,
150 /// Kernel registry for this instance.
151 pub registry: Arc<KernelRegistry>,
152 /// Last error message produced by a wasi-cuda call on this instance.
153 pub last_error: Mutex<Option<String>>,
154 /// Back-pressure cap shared across launches. Wrapped in `Arc` so an
155 /// executor (S7-style) can construct one cap and hand a clone to each
156 /// per-instance context — making the limit a process-wide ceiling rather
157 /// than a per-instance one. With [`WasiCudaContext::new`] each context
158 /// still gets its own cap.
159 pub back_pressure: Arc<BackPressure>,
160 /// The most recent successfully-parsed kernel argv from a `launch`
161 /// call. On the no-CUDA host-stub path this is the only place the
162 /// lowered args land — integration tests inspect it to confirm the
163 /// argv made it through bounds-checking and type-tag parsing
164 /// without actually launching a kernel. On CUDA builds it is also
165 /// populated (after the launch returns) so the same observability
166 /// works under `--features cuda`.
167 ///
168 /// HAZARD: pointer args inside [`LoweredArg::Ptr`] carry raw host
169 /// pointers into the guest's linear memory. Those pointers are
170 /// invalidated on any subsequent `memory.grow` by the same guest.
171 /// Treat this field as observation-only and snapshot it
172 /// immediately after the launch returns; do NOT cache the
173 /// pointers across guest-callable boundaries.
174 pub last_lowered_args: Mutex<Vec<LoweredArg>>,
175 /// Capability flag controlling whether the wasi-cuda host functions
176 /// linked via [`add_to_linker`] are allowed to perform real work on
177 /// this instance.
178 ///
179 /// Defaults to `false`. The embedder must call
180 /// [`WasiCudaContext::enable_wasi_cuda`] (or pre-set the field
181 /// directly) before the guest invokes any wasi-cuda host function.
182 /// Every host function bodies wired by `add_to_linker` short-circuits
183 /// with [`AbiError::NotAvailable`] when this is `false` — including
184 /// `last_error_len` / `last_error_copy`, so a guest cannot fingerprint
185 /// the host's wasi-cuda capability indirectly through the error
186 /// surface.
187 ///
188 /// Rationale: linking the wasi-cuda host module historically gave
189 /// every guest that imported it full driver access. Capability gating
190 /// follows the broader WASI design ("imports without capability are
191 /// inert") and lets the executor link wasi-cuda once at engine setup
192 /// while still admitting per-instance policy decisions.
193 ///
194 /// Stored as an `AtomicBool` and gated behind `pub(crate)` (wasi-gpu 1.3)
195 /// so that an embedder cannot bypass [`Self::enable_wasi_cuda`] by
196 /// writing to the field directly, and so reads from any host-import
197 /// closure observe a consistent value even if the embedder ever shared
198 /// the context across threads. Use [`Self::wasi_cuda_enabled`] /
199 /// [`Self::enable_wasi_cuda`] / [`Self::disable_wasi_cuda`].
200 pub(crate) wasi_cuda_enabled: AtomicBool,
201 /// Per-invocation absolute deadline (T36 — cooperative deadlines).
202 ///
203 /// When `Some`, the launch path constructs a deadline-aware
204 /// [`BackPressure`] clone via
205 /// [`BackPressure::with_deadline_hint`] so the acquire decision
206 /// agrees with the cooperative-yield verdict the guest sees from
207 /// `wasi:scheduler/host`. Lives behind a `Mutex` so the executor
208 /// can re-arm it at the top of each `call_export` without holding
209 /// an exclusive borrow on the context (host functions only
210 /// observe it through a borrow of `&self`).
211 ///
212 /// `None` means "no deadline configured" — the launch path falls
213 /// back to the historical `acquire_borrowed` behaviour and host
214 /// functions never reject on deadline grounds.
215 pub bp_deadline: Mutex<Option<Instant>>,
216 /// Per-instance registry of explicit device-memory allocations
217 /// (the `alloc` / `free` / `memcpy-*` host surface). Mirrors
218 /// [`registry`](Self::registry) but for device buffers: handles are
219 /// owner-scoped so a guest cannot forge another instance's handle,
220 /// and a per-instance aggregate-bytes cap bounds total pinned device
221 /// memory for this instance.
222 ///
223 /// On top of the per-instance caps, every registry (regardless of which
224 /// constructor built the context) charges the shared
225 /// [`crate::device_mem::process_device_budget`] — a process-wide ceiling
226 /// on aggregate live device bytes / allocations across *all* instances
227 /// (M-3). This is the device-memory analogue of the shared
228 /// [`back_pressure`](Self::back_pressure) cap: one process-wide ceiling
229 /// shared by every instance. Unlike `BackPressure` it does not need to be
230 /// threaded through the constructor — [`DeviceMemRegistry::new`] attaches
231 /// to the singleton automatically — so N instances cannot collectively
232 /// pin unbounded device memory even if the embedder never shares state.
233 pub device_mem: Arc<DeviceMemRegistry>,
234 /// Count of kernel launches that passed validation, acquired a
235 /// back-pressure permit, and reached the dispatch path on this
236 /// instance. Bumped on the no-CUDA stub path (just before the
237 /// `NotAvailable` return) and on the CUDA happy path. Telemetry
238 /// only — `Relaxed` ordering, surfaced via
239 /// [`InstanceMetricsSnapshot`].
240 pub(crate) kernels_launched: AtomicU64,
241 /// Count of launches refused by the back-pressure acquire path
242 /// (semaphore saturated or per-invocation deadline tripped) on this
243 /// instance. Telemetry only — `Relaxed` ordering.
244 pub(crate) back_pressure_rejections: AtomicU64,
245}
246
247impl WasiCudaContext {
248 /// Construct a fresh context for the given instance with a dedicated
249 /// (un-shared) back-pressure cap.
250 ///
251 /// The wasi-cuda capability defaults to **disabled**; the embedder
252 /// must call [`WasiCudaContext::enable_wasi_cuda`] before the guest
253 /// can use any wasi-cuda host function.
254 pub fn new(instance_id: InstanceId) -> Self {
255 Self {
256 instance_id,
257 registry: Arc::new(KernelRegistry::new()),
258 last_error: Mutex::new(None),
259 back_pressure: Arc::new(BackPressure::new()),
260 last_lowered_args: Mutex::new(Vec::new()),
261 wasi_cuda_enabled: AtomicBool::new(false),
262 bp_deadline: Mutex::new(None),
263 device_mem: Arc::new(DeviceMemRegistry::new()),
264 kernels_launched: AtomicU64::new(0),
265 back_pressure_rejections: AtomicU64::new(0),
266 }
267 }
268
269 /// Construct a context that shares the given [`BackPressure`] cap with
270 /// other contexts. Used by the executor to enforce one process-wide
271 /// concurrency limit across all Wasm instances.
272 ///
273 /// The device-memory registry it builds also charges the shared
274 /// process-wide [`crate::device_mem::process_device_budget`], so the
275 /// aggregate device-memory ceiling is enforced across every instance
276 /// alongside the shared concurrency cap (M-3) — no extra plumbing needed
277 /// at the call site.
278 ///
279 /// The wasi-cuda capability defaults to **disabled**, mirroring
280 /// [`WasiCudaContext::new`].
281 pub fn with_back_pressure(instance_id: InstanceId, bp: Arc<BackPressure>) -> Self {
282 Self {
283 instance_id,
284 registry: Arc::new(KernelRegistry::new()),
285 last_error: Mutex::new(None),
286 back_pressure: bp,
287 last_lowered_args: Mutex::new(Vec::new()),
288 wasi_cuda_enabled: AtomicBool::new(false),
289 bp_deadline: Mutex::new(None),
290 device_mem: Arc::new(DeviceMemRegistry::new()),
291 kernels_launched: AtomicU64::new(0),
292 back_pressure_rejections: AtomicU64::new(0),
293 }
294 }
295
296 /// Borrow the shared back-pressure handle for observability / sharing.
297 pub fn back_pressure(&self) -> &Arc<BackPressure> {
298 &self.back_pressure
299 }
300
301 /// Borrow the per-instance device-memory registry for observability
302 /// / sharing. The `alloc` / `free` / `memcpy-*` host functions drive
303 /// this; embedders rarely need to touch it directly.
304 pub fn device_mem(&self) -> &Arc<DeviceMemRegistry> {
305 &self.device_mem
306 }
307
308 /// Collect a read-only [`InstanceMetricsSnapshot`] for this instance.
309 ///
310 /// Pure read of the existing atomics / registry counters — never
311 /// mutates host state. The `yield_count` field is `0`; use
312 /// [`Self::metrics_snapshot_with_scheduler`] to fold in the cooperative
313 /// scheduler's yield counter when the embedder holds the matching
314 /// [`SchedulerContext`] (the wasi-cuda context does not own it).
315 pub fn metrics_snapshot(&self) -> InstanceMetricsSnapshot {
316 InstanceMetricsSnapshot {
317 kernels_launched: self.kernels_launched.load(Ordering::Relaxed),
318 bytes_pinned: self.registry.total_ptx_bytes(),
319 back_pressure_rejections: self.back_pressure_rejections.load(Ordering::Relaxed),
320 yield_count: 0,
321 device_bytes_allocated: self.device_mem.total_device_bytes(),
322 }
323 }
324
325 /// Like [`Self::metrics_snapshot`] but folds in the cooperative-yield
326 /// count from the matching [`SchedulerContext`].
327 ///
328 /// The executor keeps the wasi-cuda context and the scheduler context
329 /// as sibling per-instance fields; this accessor lets an
330 /// operator-facing metrics endpoint produce one combined snapshot from
331 /// both without the wasi-cuda context having to own the scheduler.
332 pub fn metrics_snapshot_with_scheduler(
333 &self,
334 scheduler: &SchedulerContext,
335 ) -> InstanceMetricsSnapshot {
336 let mut snap = self.metrics_snapshot();
337 snap.yield_count = scheduler.yield_count();
338 snap
339 }
340
341 /// Record that a launch reached the dispatch path. Telemetry only.
342 fn record_kernel_launched(&self) {
343 self.kernels_launched.fetch_add(1, Ordering::Relaxed);
344 }
345
346 /// Record that a launch was refused by the back-pressure path.
347 /// Telemetry only.
348 fn record_back_pressure_rejection(&self) {
349 self.back_pressure_rejections
350 .fetch_add(1, Ordering::Relaxed);
351 }
352
353 /// Install a per-invocation absolute deadline that drives the
354 /// back-pressure rejection path (T36). The same `Instant` SHOULD
355 /// be installed on the matching
356 /// [`crate::scheduler::SchedulerContext`] via
357 /// [`crate::scheduler::SchedulerContext::set_bp_deadline_instant`]
358 /// so the guest's cooperative-yield verdicts agree with the
359 /// acquire-side decisions.
360 ///
361 /// Passing `None` clears the deadline; subsequent launches fall
362 /// back to the historical `acquire_borrowed` behaviour.
363 pub fn set_bp_deadline(&self, deadline: Option<Instant>) {
364 // Recover from a poisoned mutex rather than panicking — a
365 // previous panic during a launch should not brick the
366 // deadline-update path.
367 let mut guard = self.bp_deadline.lock().unwrap_or_else(|e| e.into_inner());
368 *guard = deadline;
369 }
370
371 /// Read the currently-installed back-pressure deadline. Returns
372 /// `None` when no deadline is configured.
373 pub fn bp_deadline(&self) -> Option<Instant> {
374 *self.bp_deadline.lock().unwrap_or_else(|e| e.into_inner())
375 }
376
377 /// Build a deadline-aware [`BackPressure`] clone suitable for the
378 /// hot launch path. The returned value carries the per-instance
379 /// deadline installed via [`Self::set_bp_deadline`] (if any) but
380 /// shares the underlying semaphore Arc with every other clone
381 /// pulling from the same pool — so concurrency caps remain
382 /// process-wide while deadlines remain per-instance.
383 pub fn deadline_aware_back_pressure(&self) -> BackPressure {
384 let bp = (*self.back_pressure).clone();
385 bp.with_deadline_hint(self.bp_deadline())
386 }
387
388 /// Grant this context the wasi-cuda capability. Without this call the
389 /// linked host functions return [`AbiError::NotAvailable`] regardless
390 /// of host CUDA support — see [`WasiCudaContext::wasi_cuda_enabled`].
391 pub fn enable_wasi_cuda(&mut self) {
392 self.wasi_cuda_enabled.store(true, Ordering::Release);
393 }
394
395 /// Revoke the wasi-cuda capability granted by
396 /// [`WasiCudaContext::enable_wasi_cuda`]. Subsequent host calls degrade
397 /// to [`AbiError::NotAvailable`]. Also clears any previously-recorded
398 /// `last_error` so flipping the capability cannot let a guest read
399 /// state recorded while the capability was disabled (wasi-gpu 1.5
400 /// follow-up).
401 pub fn disable_wasi_cuda(&mut self) {
402 self.wasi_cuda_enabled.store(false, Ordering::Release);
403 if let Ok(mut guard) = self.last_error.lock() {
404 *guard = None;
405 }
406 }
407
408 /// `true` when [`WasiCudaContext::enable_wasi_cuda`] has been called.
409 pub fn wasi_cuda_enabled(&self) -> bool {
410 self.wasi_cuda_enabled.load(Ordering::Acquire)
411 }
412
413 fn record_error(&self, msg: impl Into<String>) {
414 let mut msg = msg.into();
415 // Cap the recorded message so a guest looping `launch` with
416 // malformed input cannot keep forcing large `format!` allocations
417 // that are immediately discarded on the next call. We must
418 // truncate on a UTF-8 boundary — `String::truncate` panics
419 // otherwise — so walk back from the cap to the largest valid
420 // boundary index. `is_char_boundary(0)` is always true, so the
421 // `unwrap_or(0)` branch is unreachable in practice but keeps the
422 // expression total.
423 if msg.len() > MAX_RECORDED_ERROR_BYTES {
424 let cutoff = (0..=MAX_RECORDED_ERROR_BYTES)
425 .rev()
426 .find(|i| msg.is_char_boundary(*i))
427 .unwrap_or(0);
428 msg.truncate(cutoff);
429 msg.push('\u{2026}');
430 }
431 warn!(target: "tensor_wasm_wasi_gpu::host", instance = %self.instance_id, %msg, "wasi-cuda error");
432 // A panicked `record_error` call earlier in the launch path would
433 // have poisoned this mutex. The error payload is still valid and
434 // we'd rather overwrite it with the current call's message than
435 // brick the rest of the instance — recover the inner String slot.
436 *self.last_error.lock().unwrap_or_else(|e| e.into_inner()) = Some(msg);
437 }
438
439 /// Test-only accessor for the truncating `record_error` path.
440 ///
441 /// Exposed so integration tests in `tests/` (which cannot reach the
442 /// private `record_error`) can exercise the cap. Production code
443 /// outside this crate has no reason to inject error messages and
444 /// should not call this method.
445 #[doc(hidden)]
446 pub fn record_error_for_test(&self, msg: impl Into<String>) {
447 self.record_error(msg);
448 }
449
450 /// Borrow the most recent error message.
451 pub fn last_error(&self) -> Option<String> {
452 // Mirror `record_error`: recover from poisoning so a single
453 // panicked call doesn't make subsequent observability queries
454 // panic too.
455 self.last_error
456 .lock()
457 .unwrap_or_else(|e| e.into_inner())
458 .clone()
459 }
460
461 /// Pointer-free snapshot suitable for observability and tests; the
462 /// host pointer is intentionally redacted to defend against
463 /// use-after-grow.
464 ///
465 /// The internal [`LoweredArg::Ptr`] variant carries a raw host
466 /// pointer into the guest's linear memory that the launch path
467 /// consumes synchronously. Any subsequent `memory.grow` by the same
468 /// guest can dangle that pointer; surfacing it to an embedder would
469 /// hand them a use-after-grow primitive whose lifetime no Rust
470 /// borrow check can express. Returning [`LoweredArgSnapshot`]
471 /// strips the raw pointer at the public boundary so embedders and
472 /// tests can still inspect the parsed-arg shape (variant,
473 /// guest-declared length, guest offset) without that hazard.
474 ///
475 /// Intended for tests and diagnostics; production code should not
476 /// depend on this value remaining stable across launches on the
477 /// same context.
478 pub fn last_lowered_args(&self) -> Vec<LoweredArgSnapshot> {
479 self.last_lowered_args
480 .lock()
481 // Recover from a poisoned lock rather than panicking: this is a
482 // PUBLIC observability accessor, so a panic here would be
483 // embedder-reachable. Mirrors every other lock site in this
484 // module (`.unwrap_or_else(|e| e.into_inner())`). The snapshot
485 // is read-only diagnostics — a partially-updated `Vec` left by a
486 // panicking writer is at worst stale, never unsound.
487 .unwrap_or_else(|e| e.into_inner())
488 .iter()
489 .map(LoweredArgSnapshot::from)
490 .collect()
491 }
492
493 /// Crate-internal variant of [`Self::last_lowered_args`] that keeps
494 /// the raw [`LoweredArg`] payload (including the host pointer
495 /// inside `Ptr` variants).
496 ///
497 /// This is the shape the launch path itself needs — the host
498 /// pointer is what eventually feeds `cuLaunchKernel`. It is
499 /// deliberately not part of the public API: see
500 /// [`Self::last_lowered_args`] for the use-after-grow rationale
501 /// behind the public redaction.
502 #[cfg(test)]
503 #[allow(dead_code)]
504 pub(crate) fn last_lowered_args_internal(&self) -> Vec<LoweredArg> {
505 self.last_lowered_args
506 .lock()
507 .unwrap_or_else(|e| e.into_inner())
508 .clone()
509 }
510}
511
512/// Aggregated, read-only view of a single instance's wasi-cuda activity.
513///
514/// Produced by [`WasiCudaContext::metrics_snapshot`] /
515/// [`WasiCudaContext::metrics_snapshot_with_scheduler`]. Every field is a
516/// pure read of an existing atomic / counter, so collecting a snapshot is
517/// cheap and never mutates host state — it is safe to call from an
518/// operator-facing metrics endpoint on the hot path.
519///
520/// Counter semantics:
521/// - [`kernels_launched`](Self::kernels_launched) and
522/// [`back_pressure_rejections`](Self::back_pressure_rejections) are
523/// monotonically-increasing lifetime counters for the instance.
524/// - [`bytes_pinned`](Self::bytes_pinned) and
525/// [`device_bytes_allocated`](Self::device_bytes_allocated) are *current*
526/// gauges (sum over live registry entries), so they fall when kernels /
527/// buffers are released.
528/// - [`yield_count`](Self::yield_count) comes from the matching
529/// [`SchedulerContext`]; it is `0` when a snapshot is taken without one
530/// (the wasi-cuda context does not own the scheduler — they are sibling
531/// fields on the executor's per-instance state).
532#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
533pub struct InstanceMetricsSnapshot {
534 /// Lifetime count of kernel launches that reached the dispatch path.
535 pub kernels_launched: u64,
536 /// Current aggregate retained PTX bytes across live kernels (the
537 /// host-memory the registry has "pinned" for this instance).
538 pub bytes_pinned: u64,
539 /// Lifetime count of launches refused by the back-pressure path
540 /// (semaphore saturated or per-invocation deadline tripped).
541 pub back_pressure_rejections: u64,
542 /// Cumulative cooperative-`yield()` calls observed by the matching
543 /// [`SchedulerContext`], or `0` when the snapshot was taken without one.
544 pub yield_count: u32,
545 /// Current aggregate live device-buffer bytes allocated via the
546 /// explicit `alloc` surface.
547 pub device_bytes_allocated: u64,
548}
549
550/// Trait implemented by store data types that can hand out a [`WasiCudaContext`].
551///
552/// `tensor-wasm-exec`'s `InstanceState` will implement this in a follow-up wiring
553/// session; defining the trait now keeps the linker registration generic.
554pub trait HasWasiCuda {
555 /// Borrow the wasi-cuda context.
556 fn wasi_cuda(&self) -> &WasiCudaContext;
557}
558
559/// Register all wasi-cuda host functions on a wasmtime `Linker`.
560///
561/// `T` is the store data type and must implement [`HasWasiCuda`].
562///
563/// `FN_LAUNCH` is registered with `func_wrap_async` so that on the CUDA
564/// path the host can `tokio::task::spawn_blocking(stream.synchronize())`
565/// without blocking the wasmtime fiber. The no-CUDA branch wraps the
566/// existing synchronous path in an immediately-ready future so a single
567/// async wrapper covers both feature configurations.
568pub fn add_to_linker<T: HasWasiCuda + Send + 'static>(
569 linker: &mut Linker<T>,
570) -> wasmtime::Result<()> {
571 // Capability gating: every host fn below first checks
572 // `wasi_cuda_enabled` on the per-instance context. Guests whose
573 // executor has not granted the capability see [`AbiError::NotAvailable`]
574 // — indistinguishable from "this host lacks a GPU", which is the
575 // desired posture (the guest cannot fingerprint whether the host is
576 // capability-gating or genuinely CUDA-less). The check happens before
577 // any other validation so even malformed inputs cannot be used to
578 // probe state through error-discrimination side channels.
579 linker.func_wrap(
580 MODULE,
581 FN_LOAD_PTX,
582 |mut caller: Caller<'_, T>,
583 ptx_ptr: i32,
584 ptx_len: i32,
585 entry_ptr: i32,
586 entry_len: i32|
587 -> i64 {
588 if !caller
589 .data()
590 .wasi_cuda()
591 .wasi_cuda_enabled
592 .load(Ordering::Acquire)
593 {
594 // wasi-gpu 1.5: do NOT record_error on the disabled-capability
595 // path. Matching the FN_LAST_ERROR_* gate, a recorded
596 // message would (a) burn allocations + mutex traffic for a
597 // hostile guest that hammers disabled calls, and (b)
598 // become readable if the embedder ever flips the capability
599 // back on. The NotAvailable code is the signal callers get.
600 return AbiError::NotAvailable.code() as i64;
601 }
602 match load_ptx_impl(&mut caller, ptx_ptr, ptx_len, entry_ptr, entry_len) {
603 Ok(k) => k.0 as i64,
604 Err(e) => e.code() as i64,
605 }
606 },
607 )?;
608
609 linker.func_wrap_async(
610 MODULE,
611 FN_LAUNCH,
612 |mut caller: Caller<'_, T>,
613 (
614 kernel_id,
615 grid_x,
616 grid_y,
617 grid_z,
618 block_x,
619 block_y,
620 block_z,
621 shared_mem,
622 args_ptr,
623 args_len,
624 ): (i64, i32, i32, i32, i32, i32, i32, i32, i32, i32)|
625 -> Box<dyn std::future::Future<Output = i32> + Send + '_> {
626 Box::new(async move {
627 if !caller
628 .data()
629 .wasi_cuda()
630 .wasi_cuda_enabled
631 .load(Ordering::Acquire)
632 {
633 // wasi-gpu 1.5: see the load_ptx branch above for why
634 // we skip record_error here.
635 return AbiError::NotAvailable.code();
636 }
637 launch_impl_async(
638 &mut caller,
639 kernel_id,
640 grid_x,
641 grid_y,
642 grid_z,
643 block_x,
644 block_y,
645 block_z,
646 shared_mem,
647 args_ptr,
648 args_len,
649 )
650 .await
651 .map_or_else(|e| e.code(), |_| 0)
652 })
653 },
654 )?;
655
656 linker.func_wrap(MODULE, FN_SYNC, |caller: Caller<'_, T>| -> i32 {
657 if !caller
658 .data()
659 .wasi_cuda()
660 .wasi_cuda_enabled
661 .load(Ordering::Acquire)
662 {
663 // wasi-gpu 1.5: see the load_ptx branch above for the rationale.
664 return AbiError::NotAvailable.code();
665 }
666 sync_impl(&caller).map_or_else(|e| e.code(), |_| 0)
667 })?;
668
669 // Explicit device-memory surface (alloc / free / memcpy-h2d /
670 // memcpy-d2h). The raw ABI is i32-only, so the WIT-level `u64` size and
671 // `device-handle` are split into `(lo, hi)` i32 halves on the wire and
672 // reassembled host-side by `join_u64`. Every body is capability-gated
673 // exactly like the launch path above.
674 linker.func_wrap(
675 MODULE,
676 FN_ALLOC,
677 |mut caller: Caller<'_, T>, size_lo: i32, size_hi: i32| -> i64 {
678 if !caller
679 .data()
680 .wasi_cuda()
681 .wasi_cuda_enabled
682 .load(Ordering::Acquire)
683 {
684 return AbiError::NotAvailable.code() as i64;
685 }
686 match alloc_impl(&mut caller, join_u64(size_lo, size_hi)) {
687 Ok(handle) => handle as i64,
688 Err(e) => e.code() as i64,
689 }
690 },
691 )?;
692
693 linker.func_wrap(
694 MODULE,
695 FN_FREE,
696 |mut caller: Caller<'_, T>, handle_lo: i32, handle_hi: i32| -> i32 {
697 if !caller
698 .data()
699 .wasi_cuda()
700 .wasi_cuda_enabled
701 .load(Ordering::Acquire)
702 {
703 return AbiError::NotAvailable.code();
704 }
705 free_impl(&mut caller, join_u64(handle_lo, handle_hi)).map_or_else(|e| e.code(), |_| 0)
706 },
707 )?;
708
709 linker.func_wrap(
710 MODULE,
711 FN_MEMCPY_H2D,
712 |mut caller: Caller<'_, T>,
713 handle_lo: i32,
714 handle_hi: i32,
715 src_ptr: i32,
716 len: i32|
717 -> i32 {
718 if !caller
719 .data()
720 .wasi_cuda()
721 .wasi_cuda_enabled
722 .load(Ordering::Acquire)
723 {
724 return AbiError::NotAvailable.code();
725 }
726 memcpy_h2d_impl(&mut caller, join_u64(handle_lo, handle_hi), src_ptr, len)
727 .map_or_else(|e| e.code(), |_| 0)
728 },
729 )?;
730
731 linker.func_wrap(
732 MODULE,
733 FN_MEMCPY_D2H,
734 |mut caller: Caller<'_, T>,
735 dst_ptr: i32,
736 handle_lo: i32,
737 handle_hi: i32,
738 len: i32|
739 -> i32 {
740 if !caller
741 .data()
742 .wasi_cuda()
743 .wasi_cuda_enabled
744 .load(Ordering::Acquire)
745 {
746 return AbiError::NotAvailable.code();
747 }
748 memcpy_d2h_impl(&mut caller, dst_ptr, join_u64(handle_lo, handle_hi), len)
749 .map_or_else(|e| e.code(), |_| 0)
750 },
751 )?;
752
753 // Note: `FN_LAST_ERROR_PTR` is deliberately NOT registered. The original
754 // "host hands the guest a pointer into a pre-allocated buffer" shape
755 // required coordination with the Wasm module's allocator; the
756 // `last_error_copy` design below is the working path — the guest calls
757 // `last_error_len` to learn the size, allocates its own buffer, and
758 // hands the host a `(dst_ptr, dst_len)` pair to write into. The
759 // `FN_LAST_ERROR_PTR` constant is preserved in `abi.rs` for ABI
760 // backwards-compat but is now an unimported name from the guest's POV.
761
762 linker.func_wrap(MODULE, FN_LAST_ERROR_LEN, |caller: Caller<'_, T>| -> i32 {
763 if !caller
764 .data()
765 .wasi_cuda()
766 .wasi_cuda_enabled
767 .load(Ordering::Acquire)
768 {
769 // Note: we do NOT call `record_error` here — the guest could
770 // read that recorded message back via this same surface,
771 // turning the gate into a leak channel. Returning the
772 // negative `NotAvailable` code is unambiguous: a positive
773 // `n > 0` is a real length, `0` means "no error" on a
774 // gate-passing context, and a negative value means "the
775 // surface is unavailable on this instance."
776 return AbiError::NotAvailable.code();
777 }
778 caller
779 .data()
780 .wasi_cuda()
781 .last_error()
782 .map(|s| s.len() as i32)
783 .unwrap_or(0)
784 })?;
785
786 linker.func_wrap(
787 MODULE,
788 FN_LAST_ERROR_COPY,
789 |mut caller: Caller<'_, T>, dst_ptr: i32, dst_len: i32| -> i32 {
790 if !caller
791 .data()
792 .wasi_cuda()
793 .wasi_cuda_enabled
794 .load(Ordering::Acquire)
795 {
796 // See the matching note on FN_LAST_ERROR_LEN: keep the
797 // failure shape distinct from "no error" without recording
798 // anything the guest could subsequently observe.
799 return AbiError::NotAvailable.code();
800 }
801 // Sentinel return values:
802 // `0` — no error currently recorded.
803 // `-2` (`AbiError::InvalidPointer.code()`) — the guest's
804 // `(dst_ptr, dst_len)` is invalid or the write into linear
805 // memory failed.
806 // `n > 0` — number of bytes copied.
807 // Distinguishing "no error" from "invalid pointer" matters: an
808 // earlier version returned `0` on the write-failure path, which
809 // made buggy guests silently consume corrupted error info.
810 if dst_ptr < 0 || dst_len < 0 {
811 return AbiError::InvalidPointer.code();
812 }
813 if dst_len == 0 {
814 // Zero-length destination: technically valid but copies
815 // nothing. Return 0 (matches "no error") rather than
816 // InvalidPointer; the guest knows it asked for 0 bytes.
817 return 0;
818 }
819 let msg = match caller.data().wasi_cuda().last_error() {
820 Some(s) => s,
821 None => return 0,
822 };
823 let bytes = msg.as_bytes();
824 let to_copy = std::cmp::min(bytes.len(), dst_len as usize);
825 let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) {
826 Some(m) => m,
827 None => return AbiError::InvalidPointer.code(),
828 };
829 // Pre-validate the destination region against the current
830 // memory size so a failed write returns InvalidPointer rather
831 // than the ambiguous 0.
832 let mem_len = memory.data(&caller).len();
833 let start = dst_ptr as usize;
834 let end = match start.checked_add(to_copy) {
835 Some(e) => e,
836 None => return AbiError::InvalidPointer.code(),
837 };
838 if end > mem_len {
839 return AbiError::InvalidPointer.code();
840 }
841 let buf = bytes[..to_copy].to_vec();
842 if memory.write(&mut caller, dst_ptr as usize, &buf).is_err() {
843 return AbiError::InvalidPointer.code();
844 }
845 to_copy as i32
846 },
847 )?;
848
849 Ok(())
850}
851
852fn read_bytes<T>(caller: &mut Caller<'_, T>, ptr: i32, len: i32) -> Result<Vec<u8>, AbiError> {
853 if len < 0 || ptr < 0 {
854 return Err(AbiError::InvalidPointer);
855 }
856 let memory = caller
857 .get_export("memory")
858 .and_then(|e| e.into_memory())
859 .ok_or(AbiError::InvalidPointer)?;
860 let data = memory.data(&caller);
861 let start = ptr as usize;
862 // `checked_add` here catches `ptr + len > usize::MAX`; without it a
863 // guest could ask for `(ptr = usize::MAX - 1, len = 4)` and wrap to a
864 // small `end` that looks in-bounds.
865 let end = start
866 .checked_add(len as usize)
867 .ok_or(AbiError::InvalidPointer)?;
868 if end > data.len() {
869 return Err(AbiError::InvalidPointer);
870 }
871 Ok(data[start..end].to_vec())
872}
873
874/// Reassemble a `u64` from the two i32 halves the i32-only ABI carries.
875///
876/// The WIT-level `u64` (`alloc` size, `device-handle`) is split into a low
877/// and high 32-bit word on the wire — see the `FN_ALLOC` / `FN_FREE` doc
878/// comments in `abi.rs`. Each half is reinterpreted through `as u32` so a
879/// guest that set the high bit (a "negative" i32) round-trips to the
880/// intended unsigned value.
881fn join_u64(lo: i32, hi: i32) -> u64 {
882 ((hi as u32 as u64) << 32) | (lo as u32 as u64)
883}
884
885/// Validate that `[ptr, ptr + len)` is a real region inside the caller's
886/// linear memory, returning the `(start, end)` byte range on success.
887///
888/// `ptr` / `len` arrive as i32 from the wire but model WIT `u32`, so we
889/// reinterpret through `as u32` (a guest may legitimately pass an offset
890/// with the high bit set). Mirrors the `checked_add` + bounds pattern in
891/// [`read_bytes`] and `validate_launch_args`: an overflow or an
892/// out-of-bounds end returns [`AbiError::InvalidPointer`].
893fn checked_guest_region<T>(
894 caller: &mut Caller<'_, T>,
895 ptr: i32,
896 len: u32,
897) -> Result<(usize, usize), AbiError> {
898 let memory = caller
899 .get_export("memory")
900 .and_then(|e| e.into_memory())
901 .ok_or(AbiError::InvalidPointer)?;
902 let mem_len = memory.data(&caller).len();
903 let start = ptr as u32 as usize;
904 let end = start
905 .checked_add(len as usize)
906 .ok_or(AbiError::InvalidPointer)?;
907 if end > mem_len {
908 return Err(AbiError::InvalidPointer);
909 }
910 Ok((start, end))
911}
912
913/// `alloc(size)` host implementation.
914///
915/// Validates the size against [`MAX_DEVICE_ALLOC_BYTES`] (zero-size →
916/// [`AbiError::InvalidArgs`]; oversize → [`AbiError::QuotaExceeded`]), then
917/// reserves a handle in the per-instance [`DeviceMemRegistry`] (which
918/// enforces the count + aggregate-bytes caps). On the no-CUDA path no real
919/// device memory is allocated and the call returns [`AbiError::NotAvailable`]
920/// *after* the handle is recorded — mirroring the launch stub so tests can
921/// still exercise the registry lifecycle. On the CUDA path the real
922/// `cuMemAlloc` runs first and its device pointer is stored in the entry.
923fn alloc_impl<T: HasWasiCuda>(caller: &mut Caller<'_, T>, size: u64) -> Result<u64, AbiError> {
924 let _span = info_span!(
925 "wasi_cuda.alloc",
926 instance = %caller.data().wasi_cuda().instance_id,
927 size = size,
928 )
929 .entered();
930 if size == 0 {
931 caller
932 .data()
933 .wasi_cuda()
934 .record_error("alloc: size must be > 0");
935 return Err(AbiError::InvalidArgs);
936 }
937 if size > MAX_DEVICE_ALLOC_BYTES {
938 caller.data().wasi_cuda().record_error(format!(
939 "alloc: size {size} exceeds MAX_DEVICE_ALLOC_BYTES {MAX_DEVICE_ALLOC_BYTES}"
940 ));
941 return Err(AbiError::QuotaExceeded);
942 }
943 let owner = caller.data().wasi_cuda().instance_id;
944 let device_mem = caller.data().wasi_cuda().device_mem.clone();
945
946 #[cfg(not(feature = "cuda"))]
947 {
948 // No device to allocate from, but we still track the handle in the
949 // per-instance registry — exactly like the launch stub records its
950 // parsed argv before returning `NotAvailable`. This exercises the
951 // count + aggregate-bytes caps and the owner check on the no-CUDA
952 // path so a guest's `free` of the handle (and the metrics
953 // device-bytes gauge) behave consistently across feature configs.
954 // The handle is retained (not rolled back) so the alloc→free
955 // lifecycle is observable; the wire return is still `NotAvailable`.
956 let _handle = device_mem.insert(DeviceMemEntry { owner, size })?;
957 caller.data().wasi_cuda().record_error(format!(
958 "alloc: CUDA not available on this host (requested {size} bytes; \
959 handle tracked in registry)"
960 ));
961 Err(AbiError::NotAvailable)
962 }
963
964 #[cfg(feature = "cuda")]
965 {
966 // UNVERIFIED-PENDING-HARDWARE: this branch is compile-tested on
967 // CUDA hosts only and has not been exercised on real GPU hardware.
968 // It is written against the same cust 0.3.x surface the launch path
969 // uses (`cust::sys` raw driver calls). Keep it in lockstep with the
970 // cust API if a future bump renames these symbols.
971 //
972 // `cuMemAlloc` returns a `CUdeviceptr`; we store it in the registry
973 // entry so the memcpy paths can drive `cuMemcpyHtoD` /
974 // `cuMemcpyDtoH` against it. On any driver error we record the
975 // status and return `LaunchFailed` (the existing "driver said no"
976 // code).
977 // Defense-in-depth: bind device 0's primary context on the calling
978 // thread before the driver alloc. The host fn may run on a wasmtime
979 // worker thread that never bound it; without this, `cuMemAlloc` fails
980 // with a confusing `LaunchFailed`. Mirrors `sync_impl`.
981 if let Err(e) = crate::cuda_ctx::ensure_current_context() {
982 caller
983 .data()
984 .wasi_cuda()
985 .record_error(format!("alloc: CUDA context bind failed: {e}"));
986 return Err(AbiError::LaunchFailed);
987 }
988 use cust::sys as cuda_sys;
989 let mut device_ptr: cuda_sys::CUdeviceptr = 0;
990 // SAFETY: `cuMemAlloc` writes a fresh device pointer into
991 // `device_ptr`; `size` is bounded by MAX_DEVICE_ALLOC_BYTES above.
992 let status = unsafe { cuda_sys::cuMemAlloc_v2(&mut device_ptr, size as usize) };
993 if status != cuda_sys::CUresult::CUDA_SUCCESS {
994 caller
995 .data()
996 .wasi_cuda()
997 .record_error(format!("alloc: cuMemAlloc failed with status {status:?}"));
998 return Err(AbiError::LaunchFailed);
999 }
1000 let handle = match device_mem.insert(DeviceMemEntry {
1001 owner,
1002 size,
1003 device_ptr,
1004 }) {
1005 Ok(h) => h,
1006 Err(e) => {
1007 // Registry cap tripped after the driver alloc succeeded:
1008 // free the device memory we just grabbed so the cap
1009 // rejection does not leak it.
1010 // SAFETY: `device_ptr` is the value cuMemAlloc just wrote.
1011 unsafe {
1012 let _ = cuda_sys::cuMemFree_v2(device_ptr);
1013 }
1014 return Err(e);
1015 }
1016 };
1017 Ok(handle)
1018 }
1019}
1020
1021/// `free(handle)` host implementation.
1022///
1023/// Removes the owner's allocation from the registry (cross-owner / unknown
1024/// / double-free → [`AbiError::InvalidHandle`]). On the CUDA path the real
1025/// `cuMemFree` runs against the stored device pointer.
1026fn free_impl<T: HasWasiCuda>(caller: &mut Caller<'_, T>, handle: u64) -> Result<(), AbiError> {
1027 let _span = info_span!(
1028 "wasi_cuda.free",
1029 instance = %caller.data().wasi_cuda().instance_id,
1030 handle = handle,
1031 )
1032 .entered();
1033 let owner = caller.data().wasi_cuda().instance_id;
1034 let device_mem = caller.data().wasi_cuda().device_mem.clone();
1035 let entry = match device_mem.free(handle, owner) {
1036 Ok(e) => e,
1037 Err(e) => {
1038 caller
1039 .data()
1040 .wasi_cuda()
1041 .record_error(format!("free: handle {handle} {}", e.name()));
1042 return Err(e);
1043 }
1044 };
1045 let _ = &entry;
1046
1047 #[cfg(feature = "cuda")]
1048 {
1049 // UNVERIFIED-PENDING-HARDWARE: see `alloc_impl`. Release the device
1050 // pointer recorded at alloc time. A free that the registry accepted
1051 // but the driver rejects is logged but still reported as success —
1052 // the registry slot is already gone, so the guest's view (handle no
1053 // longer valid) is correct regardless of the driver's verdict.
1054 use cust::sys as cuda_sys;
1055 // Defense-in-depth: bind the primary context before the driver free
1056 // (the host fn may run on a thread that never bound it). Consistent
1057 // with this fn's contract, a bind failure is logged but the free still
1058 // reports success — the registry slot is already gone, so the guest's
1059 // handle is invalid regardless; the device pointer leaks, the same as
1060 // a tolerated `cuMemFree` failure.
1061 if let Err(e) = crate::cuda_ctx::ensure_current_context() {
1062 caller
1063 .data()
1064 .wasi_cuda()
1065 .record_error(format!("free: CUDA context bind failed: {e}"));
1066 } else {
1067 // SAFETY: `entry.device_ptr` was produced by `cuMemAlloc` in
1068 // `alloc_impl` and has not been freed (the registry slot guaranteed
1069 // single ownership until this `free`).
1070 let status = unsafe { cuda_sys::cuMemFree_v2(entry.device_ptr) };
1071 if status != cuda_sys::CUresult::CUDA_SUCCESS {
1072 caller
1073 .data()
1074 .wasi_cuda()
1075 .record_error(format!("free: cuMemFree failed with status {status:?}"));
1076 }
1077 }
1078 }
1079
1080 Ok(())
1081}
1082
1083/// `memcpy_h2d(handle, src_ptr, len)` host implementation.
1084///
1085/// Bounds-checks the guest source region, checks `len` against the buffer's
1086/// allocated size, and copies host→device. On the no-CUDA path the
1087/// validation runs and the call returns [`AbiError::NotAvailable`].
1088fn memcpy_h2d_impl<T: HasWasiCuda>(
1089 caller: &mut Caller<'_, T>,
1090 handle: u64,
1091 src_ptr: i32,
1092 len: i32,
1093) -> Result<(), AbiError> {
1094 let _span = info_span!(
1095 "wasi_cuda.memcpy_h2d",
1096 instance = %caller.data().wasi_cuda().instance_id,
1097 handle = handle,
1098 )
1099 .entered();
1100 let owner = caller.data().wasi_cuda().instance_id;
1101 let device_mem = caller.data().wasi_cuda().device_mem.clone();
1102 let dev = match device_mem.lookup(handle, owner) {
1103 Ok(d) => d,
1104 Err(e) => {
1105 caller
1106 .data()
1107 .wasi_cuda()
1108 .record_error(format!("memcpy_h2d: handle {handle} {}", e.name()));
1109 return Err(e);
1110 }
1111 };
1112 let len_u32 = len as u32;
1113 // A copy longer than the buffer is a structural argument error — the
1114 // guest asked to write past the end of its own device allocation.
1115 if (len_u32 as u64) > dev.size {
1116 caller.data().wasi_cuda().record_error(format!(
1117 "memcpy_h2d: len {len_u32} exceeds device buffer size {}",
1118 dev.size
1119 ));
1120 return Err(AbiError::InvalidArgs);
1121 }
1122 // Bounds-check the guest source region BEFORE any driver work, so an OOB
1123 // copy surfaces as InvalidPointer (memory fault) rather than a driver
1124 // error.
1125 let (start, end) = match checked_guest_region(caller, src_ptr, len_u32) {
1126 Ok(r) => r,
1127 Err(e) => {
1128 caller.data().wasi_cuda().record_error(format!(
1129 "memcpy_h2d: source region [{src_ptr}, +{len_u32}) out of bounds"
1130 ));
1131 return Err(e);
1132 }
1133 };
1134 let _ = (start, end);
1135
1136 #[cfg(feature = "cuda")]
1137 {
1138 // UNVERIFIED-PENDING-HARDWARE: see `alloc_impl`. Copy the validated
1139 // guest bytes into the device buffer via `cuMemcpyHtoD`. We take a
1140 // fresh `Memory::data` borrow here (no await has happened since the
1141 // bounds-check, so the slice is still valid) and hand its base
1142 // pointer to the driver.
1143 // Defense-in-depth: bind the primary context before the driver copy
1144 // (the host fn may run on a thread that never bound it). Mirrors
1145 // `sync_impl`.
1146 if let Err(e) = crate::cuda_ctx::ensure_current_context() {
1147 caller
1148 .data()
1149 .wasi_cuda()
1150 .record_error(format!("memcpy_h2d: CUDA context bind failed: {e}"));
1151 return Err(AbiError::LaunchFailed);
1152 }
1153 use cust::sys as cuda_sys;
1154 let memory = caller
1155 .get_export("memory")
1156 .and_then(|e| e.into_memory())
1157 .ok_or(AbiError::InvalidPointer)?;
1158 let src = &memory.data(&caller)[start..end];
1159 // SAFETY: `dev.device_ptr` is a live `cuMemAlloc` pointer of at
1160 // least `dev.size >= len_u32` bytes; `src` is `len_u32` bytes inside
1161 // the caller's linear memory (bounds-checked above).
1162 let status = unsafe {
1163 cuda_sys::cuMemcpyHtoD_v2(
1164 dev.device_ptr,
1165 src.as_ptr() as *const std::ffi::c_void,
1166 len_u32 as usize,
1167 )
1168 };
1169 if status != cuda_sys::CUresult::CUDA_SUCCESS {
1170 caller
1171 .data()
1172 .wasi_cuda()
1173 .record_error(format!("memcpy_h2d: cuMemcpyHtoD failed: {status:?}"));
1174 return Err(AbiError::LaunchFailed);
1175 }
1176 return Ok(());
1177 }
1178
1179 #[cfg(not(feature = "cuda"))]
1180 {
1181 caller
1182 .data()
1183 .wasi_cuda()
1184 .record_error("memcpy_h2d: CUDA not available on this host");
1185 Err(AbiError::NotAvailable)
1186 }
1187}
1188
1189/// `memcpy_d2h(dst_ptr, handle, len)` host implementation.
1190///
1191/// Bounds-checks the guest destination region, checks `len` against the
1192/// buffer's allocated size, and copies device→host. On the no-CUDA path the
1193/// validation runs and the call returns [`AbiError::NotAvailable`].
1194fn memcpy_d2h_impl<T: HasWasiCuda>(
1195 caller: &mut Caller<'_, T>,
1196 dst_ptr: i32,
1197 handle: u64,
1198 len: i32,
1199) -> Result<(), AbiError> {
1200 let _span = info_span!(
1201 "wasi_cuda.memcpy_d2h",
1202 instance = %caller.data().wasi_cuda().instance_id,
1203 handle = handle,
1204 )
1205 .entered();
1206 let owner = caller.data().wasi_cuda().instance_id;
1207 let device_mem = caller.data().wasi_cuda().device_mem.clone();
1208 let dev = match device_mem.lookup(handle, owner) {
1209 Ok(d) => d,
1210 Err(e) => {
1211 caller
1212 .data()
1213 .wasi_cuda()
1214 .record_error(format!("memcpy_d2h: handle {handle} {}", e.name()));
1215 return Err(e);
1216 }
1217 };
1218 let len_u32 = len as u32;
1219 if (len_u32 as u64) > dev.size {
1220 caller.data().wasi_cuda().record_error(format!(
1221 "memcpy_d2h: len {len_u32} exceeds device buffer size {}",
1222 dev.size
1223 ));
1224 return Err(AbiError::InvalidArgs);
1225 }
1226 let (start, end) = match checked_guest_region(caller, dst_ptr, len_u32) {
1227 Ok(r) => r,
1228 Err(e) => {
1229 caller.data().wasi_cuda().record_error(format!(
1230 "memcpy_d2h: dest region [{dst_ptr}, +{len_u32}) out of bounds"
1231 ));
1232 return Err(e);
1233 }
1234 };
1235 let _ = (start, end);
1236
1237 #[cfg(feature = "cuda")]
1238 {
1239 // UNVERIFIED-PENDING-HARDWARE: see `alloc_impl`. Copy device bytes
1240 // back into the validated guest region via `cuMemcpyDtoH`.
1241 // Defense-in-depth: bind the primary context before the driver copy
1242 // (the host fn may run on a thread that never bound it). Mirrors
1243 // `sync_impl`.
1244 if let Err(e) = crate::cuda_ctx::ensure_current_context() {
1245 caller
1246 .data()
1247 .wasi_cuda()
1248 .record_error(format!("memcpy_d2h: CUDA context bind failed: {e}"));
1249 return Err(AbiError::LaunchFailed);
1250 }
1251 use cust::sys as cuda_sys;
1252 let memory = caller
1253 .get_export("memory")
1254 .and_then(|e| e.into_memory())
1255 .ok_or(AbiError::InvalidPointer)?;
1256 let dst = &mut memory.data_mut(&mut *caller)[start..end];
1257 // SAFETY: `dev.device_ptr` is a live `cuMemAlloc` pointer of at
1258 // least `len_u32` bytes; `dst` is `len_u32` writable bytes inside
1259 // the caller's linear memory (bounds-checked above).
1260 let status = unsafe {
1261 cuda_sys::cuMemcpyDtoH_v2(
1262 dst.as_mut_ptr() as *mut std::ffi::c_void,
1263 dev.device_ptr,
1264 len_u32 as usize,
1265 )
1266 };
1267 if status != cuda_sys::CUresult::CUDA_SUCCESS {
1268 caller
1269 .data()
1270 .wasi_cuda()
1271 .record_error(format!("memcpy_d2h: cuMemcpyDtoH failed: {status:?}"));
1272 return Err(AbiError::LaunchFailed);
1273 }
1274 return Ok(());
1275 }
1276
1277 #[cfg(not(feature = "cuda"))]
1278 {
1279 caller
1280 .data()
1281 .wasi_cuda()
1282 .record_error("memcpy_d2h: CUDA not available on this host");
1283 Err(AbiError::NotAvailable)
1284 }
1285}
1286
1287fn load_ptx_impl<T: HasWasiCuda>(
1288 caller: &mut Caller<'_, T>,
1289 ptx_ptr: i32,
1290 ptx_len: i32,
1291 entry_ptr: i32,
1292 entry_len: i32,
1293) -> Result<KernelId, AbiError> {
1294 let _span = info_span!(
1295 "wasi_cuda.load_ptx",
1296 instance = %caller.data().wasi_cuda().instance_id,
1297 ptx_bytes = ptx_len as u64,
1298 entry_bytes = entry_len as u64,
1299 )
1300 .entered();
1301 // LOW finding: check `ptx_len < 0` BEFORE the cap comparison below.
1302 // `ptx_len` is i32 from the wire; a negative value cast through
1303 // `as usize` becomes a huge number that would trip the
1304 // QuotaExceeded/`MAX_PTX_BYTES` branch and misreport an invalid-pointer
1305 // condition as "input too large." `read_bytes` would ultimately reject
1306 // the negative length with `InvalidPointer` anyway; surfacing that code
1307 // here keeps parity with the `entry_len < 0` check just below.
1308 if ptx_len < 0 {
1309 caller
1310 .data()
1311 .wasi_cuda()
1312 .record_error(format!("load_ptx: negative ptx_len ({ptx_len})"));
1313 return Err(AbiError::InvalidPointer);
1314 }
1315 if (ptx_len as usize) > MAX_PTX_BYTES {
1316 caller.data().wasi_cuda().record_error(format!(
1317 "load_ptx: ptx_len {ptx_len} exceeds MAX_PTX_BYTES {MAX_PTX_BYTES}"
1318 ));
1319 return Err(AbiError::QuotaExceeded);
1320 }
1321 // Bound the entry-name length BEFORE `read_bytes` so a guest cannot
1322 // force a multi-MiB UTF-8 validation + `String::from` allocation per
1323 // call. `entry_len` is i32 from the wire; the negative-check inside
1324 // `read_bytes` would still catch a negative value later, but checking
1325 // the positive overflow here lets us reject without ever copying out
1326 // of linear memory. We surface `QuotaExceeded` to match the existing
1327 // PTX-bytes cap above — both are "input too large" failures from the
1328 // guest's POV.
1329 if entry_len < 0 || (entry_len as usize) > MAX_ENTRY_NAME_BYTES {
1330 caller.data().wasi_cuda().record_error(format!(
1331 "load_ptx: entry_len {entry_len} exceeds MAX_ENTRY_NAME_BYTES {MAX_ENTRY_NAME_BYTES}"
1332 ));
1333 return Err(AbiError::QuotaExceeded);
1334 }
1335 let ptx = read_bytes(caller, ptx_ptr, ptx_len)?;
1336 let entry_bytes = read_bytes(caller, entry_ptr, entry_len)?;
1337 let entry = String::from_utf8(entry_bytes).map_err(|_| {
1338 caller
1339 .data()
1340 .wasi_cuda()
1341 .record_error("load_ptx: entry name is not valid UTF-8");
1342 AbiError::InvalidArgs
1343 })?;
1344
1345 #[cfg(not(feature = "cuda"))]
1346 {
1347 // Validate format minimally even on the non-CUDA path: empty or
1348 // non-UTF8 PTX is malformed.
1349 if ptx.is_empty() {
1350 caller
1351 .data()
1352 .wasi_cuda()
1353 .record_error("load_ptx: PTX bytes empty");
1354 return Err(AbiError::MalformedPtx);
1355 }
1356 let ptx_str = match std::str::from_utf8(&ptx) {
1357 Ok(s) => s,
1358 Err(_) => {
1359 caller
1360 .data()
1361 .wasi_cuda()
1362 .record_error("load_ptx: PTX bytes are not valid UTF-8");
1363 return Err(AbiError::MalformedPtx);
1364 }
1365 };
1366 // Structural sanity check: every well-formed PTX file declares a
1367 // `.version`, a `.target` SM, and at least one `.entry` kernel.
1368 // Missing any of these means the blob is not a PTX module — reject
1369 // it as MalformedPtx so the stub matches the plan's S8 done-when.
1370 for directive in [".version", ".target", ".entry"] {
1371 if !ptx_str.contains(directive) {
1372 caller.data().wasi_cuda().record_error(format!(
1373 "load_ptx: PTX missing required directive {directive}"
1374 ));
1375 return Err(AbiError::MalformedPtx);
1376 }
1377 }
1378 let owner = caller.data().wasi_cuda().instance_id;
1379 let entry_record = KernelEntry {
1380 owner,
1381 entry: entry.clone(),
1382 ptx_bytes_len: ptx.len(),
1383 };
1384 let registry = caller.data().wasi_cuda().registry.clone();
1385 let id = registry.register(entry_record)?;
1386 info!(target: "tensor_wasm_wasi_gpu::host", instance = %owner, kernel = %id, entry, "PTX registered (stub: cuda feature off)");
1387 Ok(id)
1388 }
1389
1390 #[cfg(feature = "cuda")]
1391 {
1392 use cust::module::Module;
1393 // Real path: compile the PTX through cust::module::Module::from_ptx.
1394 // Module::from_ptx panics if `string` contains a nul byte, so we
1395 // explicitly reject nul bytes before handing the slice over.
1396 let ptx_str = std::str::from_utf8(&ptx).map_err(|_| {
1397 caller
1398 .data()
1399 .wasi_cuda()
1400 .record_error("load_ptx: PTX bytes are not valid UTF-8");
1401 AbiError::MalformedPtx
1402 })?;
1403 if ptx_str.as_bytes().contains(&0u8) {
1404 caller
1405 .data()
1406 .wasi_cuda()
1407 .record_error("load_ptx: PTX bytes contain an interior NUL");
1408 return Err(AbiError::MalformedPtx);
1409 }
1410 // Fix #6: the JIT compile needs a current CUDA context on THIS
1411 // thread. `load_ptx` runs on whatever thread the wasmtime fiber is
1412 // polled on, which may never have made the primary context current.
1413 // Bind it before `Module::from_ptx` so the compile cannot fail with
1414 // `CUDA_ERROR_INVALID_CONTEXT`.
1415 crate::cuda_ctx::ensure_current_context().map_err(|e| {
1416 caller
1417 .data()
1418 .wasi_cuda()
1419 .record_error(format!("load_ptx: CUDA context bind failed: {e}"));
1420 AbiError::NotAvailable
1421 })?;
1422 let module = Module::from_ptx(ptx_str, &[]).map_err(|e| {
1423 caller
1424 .data()
1425 .wasi_cuda()
1426 .record_error(format!("load_ptx: cust compile failed: {e:?}"));
1427 AbiError::MalformedPtx
1428 })?;
1429 let owner = caller.data().wasi_cuda().instance_id;
1430 let entry_record = KernelEntry {
1431 owner,
1432 entry: entry.clone(),
1433 ptx_bytes_len: ptx.len(),
1434 module: Some(Arc::new(module)),
1435 };
1436 let registry = caller.data().wasi_cuda().registry.clone();
1437 let id = registry.register(entry_record)?;
1438 info!(target: "tensor_wasm_wasi_gpu::host", instance = %owner, kernel = %id, entry, "PTX compiled and registered via cust");
1439 Ok(id)
1440 }
1441}
1442
1443/// Common argument-region validation extracted from the launch path so the
1444/// sync and async wrappers share one implementation.
1445///
1446/// Validates:
1447/// 1. `args_ptr` / `args_len` are non-negative and the region fits in
1448/// linear memory.
1449/// 2. `kernel_id` is non-negative.
1450/// 3. Block dimensions fit `[1, MAX_BLOCK_DIM]` each and the thread-per-
1451/// block product is `<= MAX_THREADS_PER_BLOCK`.
1452/// 4. Grid dimensions fit `[1, MAX_GRID_DIM]` each.
1453/// 5. `shared_mem` is in `[0, MAX_DYNAMIC_SHARED_MEM_BYTES]`.
1454///
1455/// Failures return [`AbiError::InvalidDimensions`] for dimension-cap
1456/// violations and [`AbiError::InvalidPointer`] for memory-region issues,
1457/// allowing the guest to distinguish a launch-shape bug from a memory bug.
1458#[allow(clippy::too_many_arguments)]
1459fn validate_launch_args<T: HasWasiCuda>(
1460 caller: &mut Caller<'_, T>,
1461 kernel_id: i64,
1462 grid_x: i32,
1463 grid_y: i32,
1464 grid_z: i32,
1465 block_x: i32,
1466 block_y: i32,
1467 block_z: i32,
1468 shared_mem: i32,
1469 args_ptr: i32,
1470 args_len: i32,
1471) -> Result<KernelId, AbiError> {
1472 if args_len < 0 || args_ptr < 0 {
1473 caller.data().wasi_cuda().record_error(format!(
1474 "launch: negative args_ptr ({args_ptr}) or args_len ({args_len})"
1475 ));
1476 return Err(AbiError::InvalidPointer);
1477 }
1478 if args_len > 0 {
1479 let memory = caller
1480 .get_export("memory")
1481 .and_then(|e| e.into_memory())
1482 .ok_or_else(|| {
1483 caller
1484 .data()
1485 .wasi_cuda()
1486 .record_error("launch: caller has no exported memory but args_len > 0");
1487 AbiError::InvalidPointer
1488 })?;
1489 let mem_len = memory.data(&caller).len();
1490 let start = args_ptr as usize;
1491 let end = start.checked_add(args_len as usize).ok_or_else(|| {
1492 caller.data().wasi_cuda().record_error(format!(
1493 "launch: args_ptr + args_len overflows usize ({args_ptr} + {args_len})"
1494 ));
1495 AbiError::InvalidPointer
1496 })?;
1497 if end > mem_len {
1498 caller.data().wasi_cuda().record_error(format!(
1499 "launch: args region [{start}, {end}) exceeds Wasm memory len {mem_len}"
1500 ));
1501 return Err(AbiError::InvalidPointer);
1502 }
1503 }
1504 if kernel_id < 0 {
1505 return Err(AbiError::InvalidKernel);
1506 }
1507 // Per-axis lower / upper bounds and the thread-per-block product cap.
1508 if block_x <= 0 || block_y <= 0 || block_z <= 0 {
1509 caller.data().wasi_cuda().record_error(format!(
1510 "launch: block dim must be >= 1 (got {block_x}, {block_y}, {block_z})"
1511 ));
1512 return Err(AbiError::InvalidDimensions);
1513 }
1514 if grid_x <= 0 || grid_y <= 0 || grid_z <= 0 {
1515 caller.data().wasi_cuda().record_error(format!(
1516 "launch: grid dim must be >= 1 (got {grid_x}, {grid_y}, {grid_z})"
1517 ));
1518 return Err(AbiError::InvalidDimensions);
1519 }
1520 if shared_mem < 0 {
1521 caller.data().wasi_cuda().record_error(format!(
1522 "launch: shared_mem must be >= 0 (got {shared_mem})"
1523 ));
1524 return Err(AbiError::InvalidDimensions);
1525 }
1526 // MEDIUM finding: bound `shared_mem` host-side before it is forwarded
1527 // to `cuLaunchKernel` as `shared_mem as u32` (~line 1757). Previously
1528 // any positive value up to `i32::MAX` was passed through, deferring an
1529 // obviously-bogus request to the driver. Cap it at
1530 // [`MAX_DYNAMIC_SHARED_MEM_BYTES`] and reject above it with
1531 // `InvalidDimensions`, matching the grid/block-dim posture so the
1532 // failure is actionable host-side.
1533 if shared_mem > MAX_DYNAMIC_SHARED_MEM_BYTES {
1534 caller.data().wasi_cuda().record_error(format!(
1535 "launch: shared_mem {shared_mem} exceeds MAX_DYNAMIC_SHARED_MEM_BYTES={MAX_DYNAMIC_SHARED_MEM_BYTES}"
1536 ));
1537 return Err(AbiError::InvalidDimensions);
1538 }
1539 // Per-axis block-dim ceilings (CUDA hardware: 1024 for x and y, 64 for z
1540 // on current SMs; we cap each at MAX_BLOCK_DIM and rely on the
1541 // threads-per-block product check below to catch the z-axis variant).
1542 if (block_x as u32) > MAX_BLOCK_DIM
1543 || (block_y as u32) > MAX_BLOCK_DIM
1544 || (block_z as u32) > MAX_BLOCK_DIM
1545 {
1546 caller.data().wasi_cuda().record_error(format!(
1547 "launch: block dim exceeds MAX_BLOCK_DIM={MAX_BLOCK_DIM} (got {block_x}, {block_y}, {block_z})"
1548 ));
1549 return Err(AbiError::InvalidDimensions);
1550 }
1551 let threads_per_block = (block_x as u64)
1552 .checked_mul(block_y as u64)
1553 .and_then(|v| v.checked_mul(block_z as u64))
1554 .ok_or_else(|| {
1555 caller
1556 .data()
1557 .wasi_cuda()
1558 .record_error("launch: block dim product overflows u64");
1559 AbiError::InvalidDimensions
1560 })?;
1561 if threads_per_block > MAX_THREADS_PER_BLOCK as u64 {
1562 caller.data().wasi_cuda().record_error(format!(
1563 "launch: threads-per-block {threads_per_block} exceeds MAX_THREADS_PER_BLOCK={MAX_THREADS_PER_BLOCK}"
1564 ));
1565 return Err(AbiError::InvalidDimensions);
1566 }
1567 // Grid-axis ceilings. `MAX_GRID_DIM` is 2^31 - 1 (CUDA driver max for
1568 // grid_x). i32 already enforces this implicitly (positive i32 maxes at
1569 // 2^31 - 1), but we keep the explicit cast-and-compare so a future
1570 // widening of the wire types doesn't silently raise the cap.
1571 if (grid_x as u32) > MAX_GRID_DIM
1572 || (grid_y as u32) > MAX_GRID_DIM
1573 || (grid_z as u32) > MAX_GRID_DIM
1574 {
1575 caller.data().wasi_cuda().record_error(format!(
1576 "launch: grid dim exceeds MAX_GRID_DIM={MAX_GRID_DIM} (got {grid_x}, {grid_y}, {grid_z})"
1577 ));
1578 return Err(AbiError::InvalidDimensions);
1579 }
1580 Ok(KernelId(kernel_id as u64))
1581}
1582
1583/// Asynchronous wrapper around the launch implementation.
1584///
1585/// On the no-CUDA path the body is essentially identical to the old
1586/// synchronous `launch_impl`: validate, acquire a back-pressure permit,
1587/// then return `Err(NotAvailable)`. On the CUDA path the body builds and
1588/// dispatches a real kernel via `cust`, then awaits a `spawn_blocking`
1589/// `stream.synchronize()` so the wasmtime fiber may be suspended while
1590/// the GPU runs.
1591///
1592/// ## Pointer-aliasing safety across the back-pressure await
1593///
1594/// Wasmtime's `Memory::data` borrow may be invalidated by *any* await on
1595/// the same store — including `memory.grow` triggered by an embedder
1596/// host hook. To keep raw pointers resolved by [`parse_argv`] from
1597/// becoming dangling at the `cuLaunchKernel` call, we structure the
1598/// body as:
1599///
1600/// 1. Synchronously validate dims + the outer args region.
1601/// 2. `await bp.acquire_borrowed()` — back-pressure permit, the only
1602/// await on the path.
1603/// 3. After the await resolves, synchronously snapshot the args buffer,
1604/// run `parse_argv` (which resolves guest offsets to host pointers),
1605/// stash the lowered args for observability, look up the kernel
1606/// handle, and call `cuLaunchKernel`.
1607///
1608/// Step 3 cannot cross another await (the host function holds the
1609/// wasmtime store for its full duration after the permit resolves), so
1610/// the resolved host pointers remain valid through the
1611/// `cuLaunchKernel` call. The subsequent `spawn_blocking`
1612/// `stream.synchronize()` is also safe: `cuLaunchKernel` has already
1613/// captured the pointers, and the guest cannot run (let alone grow
1614/// memory) until this async fn returns.
1615///
1616/// See the module-level docs for the kernel-args marshalling contract.
1617#[allow(clippy::too_many_arguments)]
1618async fn launch_impl_async<T: HasWasiCuda>(
1619 caller: &mut Caller<'_, T>,
1620 kernel_id: i64,
1621 grid_x: i32,
1622 grid_y: i32,
1623 grid_z: i32,
1624 block_x: i32,
1625 block_y: i32,
1626 block_z: i32,
1627 shared_mem: i32,
1628 args_ptr: i32,
1629 args_len: i32,
1630) -> Result<(), AbiError> {
1631 // Build the launch span up front and instrument the inner future with
1632 // it. `info_span!` returns a `Span` whose `.enter()` guard is `!Send`
1633 // — entering it directly here would poison the `Send` bound the
1634 // `func_wrap_async` boxed future carries. Wrapping the inner future
1635 // via `tracing::Instrument` attaches the span across `await` points
1636 // instead, so each poll re-enters the span and our log lines stay
1637 // attributed to this launch.
1638 let launch_span = info_span!(
1639 "wasi_cuda.launch",
1640 instance = %caller.data().wasi_cuda().instance_id,
1641 kernel = kernel_id,
1642 grid_x = grid_x, grid_y = grid_y, grid_z = grid_z,
1643 block_x = block_x, block_y = block_y, block_z = block_z,
1644 shared_mem = shared_mem,
1645 );
1646 launch_impl_async_inner(
1647 caller, kernel_id, grid_x, grid_y, grid_z, block_x, block_y, block_z, shared_mem, args_ptr,
1648 args_len,
1649 )
1650 .instrument(launch_span)
1651 .await
1652}
1653
1654#[allow(clippy::too_many_arguments)]
1655async fn launch_impl_async_inner<T: HasWasiCuda>(
1656 caller: &mut Caller<'_, T>,
1657 kernel_id: i64,
1658 grid_x: i32,
1659 grid_y: i32,
1660 grid_z: i32,
1661 block_x: i32,
1662 block_y: i32,
1663 block_z: i32,
1664 shared_mem: i32,
1665 args_ptr: i32,
1666 args_len: i32,
1667) -> Result<(), AbiError> {
1668 let kid = validate_launch_args(
1669 caller, kernel_id, grid_x, grid_y, grid_z, block_x, block_y, block_z, shared_mem, args_ptr,
1670 args_len,
1671 )?;
1672 let owner = caller.data().wasi_cuda().instance_id;
1673 let registry = caller.data().wasi_cuda().registry.clone();
1674
1675 // Back-pressure: on the async path we await rather than reject so the
1676 // Wasm fiber suspends when the cap is reached. The permit is held for
1677 // the lifetime of this future — on return (success OR error) it drops
1678 // and the live-counter decrements, enforcing the cap regardless of
1679 // outcome.
1680 //
1681 // CRITICAL ORDERING: the permit is acquired *before* we resolve any
1682 // pointers into guest linear memory. `parse_argv` calls
1683 // `mem.as_ptr().add(start)` on a `Memory::data(&caller)` borrow whose
1684 // pointers wasmtime is allowed to invalidate across any await on this
1685 // store. By awaiting the permit first and only then snapshotting +
1686 // parsing + dispatching to `cuLaunchKernel`, we never let a resolved
1687 // host pointer outlive the synchronous critical section that consumes
1688 // it. The remaining `spawn_blocking(stream.synchronize())` is safe
1689 // because `cuLaunchKernel` has already captured the pointers and the
1690 // guest cannot run until this fn returns.
1691 //
1692 // We use `acquire_borrowed` (not `acquire`) because this host function
1693 // never moves the permit across a `tokio::spawn` boundary: the
1694 // `spawn_blocking` call below moves only the CUDA stream/event/module
1695 // handle, not the permit. Borrowing skips the `Arc<Semaphore>` clone
1696 // the owned variant pays on every dispatch — a measurable saving on
1697 // the hot path. `&BackPressure` outlives the borrow because the
1698 // `WasiCudaContext` (which owns the Arc<BackPressure>) is held by the
1699 // wasmtime `Caller` for the duration of this async fn.
1700 // `acquire_borrowed` returns `Err(QuotaExceeded)` synchronously when
1701 // the cap is the cap-0 sentinel (no permits will ever be issued), so a
1702 // guest authored against a back-pressure-disabled embedder surfaces
1703 // the saturation error rather than hanging the wasm fiber forever.
1704 // Any other cap behaves as before: the await suspends until a permit
1705 // is released by a finishing dispatch.
1706 // T36: build a deadline-aware BackPressure clone so the acquire
1707 // path can refuse new permits when the per-invocation deadline is
1708 // near or elapsed. The underlying semaphore Arc is shared across
1709 // every per-instance clone, so cap enforcement remains
1710 // process-wide; only the deadline is per-instance. Without an
1711 // installed deadline this collapses to the pre-T36 behaviour.
1712 let bp = caller.data().wasi_cuda().deadline_aware_back_pressure();
1713 let _permit = match bp.acquire_borrowed().await {
1714 Ok(p) => p,
1715 Err(e) => {
1716 // Telemetry: a refused acquire (semaphore saturated or the
1717 // per-invocation deadline tripped) counts as a back-pressure
1718 // rejection for this instance. Pure counter bump; the error is
1719 // propagated unchanged.
1720 caller.data().wasi_cuda().record_back_pressure_rejection();
1721 return Err(e);
1722 }
1723 };
1724
1725 // Resolve argv now, after the permit has been acquired. Pointer args
1726 // are resolved against the caller's current linear-memory snapshot;
1727 // the resolution and the consuming `cuLaunchKernel` call live in this
1728 // same synchronous critical section, so the wasmtime guest cannot
1729 // run between them and the resolved pointers are guaranteed valid at
1730 // launch time. Once `cuLaunchKernel` returns, CUDA has its own copy
1731 // of the parameter slot bytes and we never re-read the resolved
1732 // pointers from this side.
1733 //
1734 // `KernelArgsUnsupported` is preserved as a fallback for buffers that
1735 // exceed the kernel-args sanity caps — see
1736 // [`crate::kernel_args::MAX_KERNEL_ARGS_BYTES`] /
1737 // [`crate::kernel_args::MAX_KERNEL_ARGS`]. Genuinely-malformed argv
1738 // (unknown tag, truncated record) surfaces as `InvalidArgs`; OOB
1739 // pointer arg returns `InvalidPointer`. The bounds-check on the
1740 // outer buffer still runs first inside `validate_launch_args`, so a
1741 // malicious guest cannot trade a `MemoryFault` for the friendlier
1742 // tag-byte error.
1743 let lowered_args: Vec<LoweredArg> = if args_len > 0 {
1744 // PERF (T23): skip the `read_bytes` Vec copy of the argv buffer.
1745 // `parse_argv` already takes both inputs as `&[u8]`, so we can
1746 // pass it slices directly into the caller's linear memory.
1747 // `validate_launch_args` above has already verified that
1748 // `args_ptr >= 0`, `args_len >= 0`, and `[args_ptr, args_ptr +
1749 // args_len) ⊆ memory`, so the slicing below cannot panic. A
1750 // single `mem.data(&caller)` borrow covers both the args region
1751 // and the whole-memory bounds-check that pointer args inside
1752 // `parse_argv` need.
1753 let mem = caller
1754 .get_export("memory")
1755 .and_then(|e| e.into_memory())
1756 .ok_or(AbiError::InvalidPointer)?;
1757 let mem_data = mem.data(&caller);
1758 let start = args_ptr as usize;
1759 let end = start + args_len as usize;
1760 let argv_slice = &mem_data[start..end];
1761 match parse_argv(argv_slice, mem_data) {
1762 Ok(v) => v,
1763 Err(e) => {
1764 caller.data().wasi_cuda().record_error(format!(
1765 "launch: kernel argv parse failed ({}); args_len={args_len}",
1766 e.name()
1767 ));
1768 return Err(e);
1769 }
1770 }
1771 } else {
1772 Vec::new()
1773 };
1774
1775 // Stash the parsed argv for observability BEFORE the kernel-handle
1776 // lookup. Tests inspect `last_lowered_args` to confirm the
1777 // marshalling round-trip held; surfacing it only after the launch
1778 // synchronizes (or after the CUDA branch's many error paths) means
1779 // a missing-PTX or stream-failure case loses the parse signal,
1780 // which is the more valuable data point for diagnostics. The CUDA
1781 // and no-CUDA branches further down both overwrite this slot on
1782 // their own happy path, so the duplication is intentional.
1783 *caller
1784 .data()
1785 .wasi_cuda()
1786 .last_lowered_args
1787 .lock()
1788 .unwrap_or_else(|e| e.into_inner()) = lowered_args.clone();
1789
1790 // Eagerly take a strong, owned handle to the kernel (Arc-wrapped on
1791 // CUDA builds). This both validates `kid` and frees the registry's
1792 // dashmap entry before any further work, eliminating the UAF window
1793 // that existed when we kept a raw pointer derived from a transient
1794 // `dashmap::Ref` alive across the launch.
1795 let handle = registry.lookup(kid, owner)?;
1796
1797 #[cfg(feature = "cuda")]
1798 {
1799 use cust::event::{Event, EventFlags};
1800 use cust::stream::{Stream, StreamFlags};
1801
1802 use crate::kernel_args::build_kernel_param_storage;
1803
1804 // Fix #6: bind the process-wide primary context to THIS thread before
1805 // any of `Stream::new` / `cuLaunchKernel` / `Event::*` run. The async
1806 // fiber may be polled on a tokio worker that has never made the context
1807 // current, which would otherwise fail every driver call here with
1808 // `CUDA_ERROR_INVALID_CONTEXT`. (The `spawn_blocking` synchronize
1809 // closure below re-binds on its own pool thread.)
1810 crate::cuda_ctx::ensure_current_context().map_err(|e| {
1811 caller
1812 .data()
1813 .wasi_cuda()
1814 .record_error(format!("launch: CUDA context bind failed: {e}"));
1815 AbiError::LaunchFailed
1816 })?;
1817
1818 // The strong `Arc` we already hold keeps the module alive across
1819 // launch + synchronize without any raw-pointer gymnastics.
1820 let module = handle.module.clone().ok_or_else(|| {
1821 caller
1822 .data()
1823 .wasi_cuda()
1824 .record_error("launch: kernel entry has no compiled module");
1825 AbiError::InvalidKernel
1826 })?;
1827
1828 let func = module.get_function(&handle.entry).map_err(|e| {
1829 caller.data().wasi_cuda().record_error(format!(
1830 "launch: get_function({}) failed: {e:?}",
1831 handle.entry
1832 ));
1833 AbiError::LaunchFailed
1834 })?;
1835
1836 let stream = Stream::new(StreamFlags::NON_BLOCKING, None).map_err(|e| {
1837 caller
1838 .data()
1839 .wasi_cuda()
1840 .record_error(format!("launch: Stream::new failed: {e:?}"));
1841 AbiError::LaunchFailed
1842 })?;
1843
1844 // Build the `void**` parameter storage from the parsed argv. The
1845 // storage owns the per-arg value bytes (scalars) and the
1846 // pointer-of-pointer slots that `cuLaunchKernel` consumes; we
1847 // keep it alive across the launch call below.
1848 //
1849 // For zero-arg launches `storage.as_ptr()` is still a valid
1850 // pointer to an empty slot vec — `cuLaunchKernel` interprets a
1851 // zero parameter count as "ignore the argv pointer."
1852 let mut storage = build_kernel_param_storage(&lowered_args);
1853 let param_count = storage.len();
1854 // CUDA reads `kernelParams` only when the kernel's PTX `.param`
1855 // block is non-empty; for zero-arg kernels we pass NULL rather
1856 // than the (possibly dangling) `Vec::as_mut_ptr()` of an empty
1857 // slot vec.
1858 let kernel_params_ptr: *mut *mut std::ffi::c_void = if param_count == 0 {
1859 std::ptr::null_mut()
1860 } else {
1861 storage.as_ptr()
1862 };
1863
1864 // Drop down to `cust::sys::cuLaunchKernel` because `cust::launch!`
1865 // forces statically-typed args at the call site. The raw call
1866 // takes a `*mut *mut c_void` of length `param_count` — exactly
1867 // what `storage.as_ptr()` provides. We pass a null `extra`
1868 // pointer because CUDA accepts either form (params XOR extra).
1869 //
1870 // NOTE: `cust 0.3`'s `Function` and `Stream` raw-handle
1871 // accessors (`as_raw` / `as_inner`) are stable across the 0.3.x
1872 // line; if a future cust bump renames them this is the only
1873 // call site that needs to follow.
1874 //
1875 // SAFETY: launching a kernel is inherently unsafe — the host has
1876 // no proof the kernel signature matches the parsed argv. The
1877 // caller (the Wasm guest) is responsible for that match; we
1878 // guarantee only that (a) every pointer arg points into the
1879 // guest's own linear memory, (b) the dims fit the CUDA caps,
1880 // and (c) the stream/function/module references are live for
1881 // the duration of the call (the `Arc<Module>` clone held in
1882 // `handle` keeps the module alive across the launch).
1883 use cust::sys as cuda_sys;
1884
1885 // SECURITY (cross-tenant context poisoning / DoS) — verify every
1886 // pointer argument is actually GPU-dereferenceable (CUDA managed
1887 // memory) BEFORE handing it to `cuLaunchKernel`.
1888 //
1889 // The argv pointer path resolves guest offsets to host addresses inside
1890 // the guest's linear memory. Those double as device addresses ONLY when
1891 // that linear memory is `cuMemAllocManaged`-backed (the unified-memory
1892 // `MemoryCreator`). If an embedder runs `--features cuda` with plain
1893 // host-heap linear memory, the addresses are not valid on the GPU and
1894 // `cuLaunchKernel` makes the kernel dereference host memory, raising
1895 // `CUDA_ERROR_ILLEGAL_ADDRESS`. That error is STICKY: it poisons the
1896 // process-shared CUDA context, so every later CUDA op by EVERY tenant in
1897 // the process then fails. A single guest could thus take down GPU
1898 // offload for the whole host. (This is also what intermittently broke
1899 // the launch e2e suite when an earlier test launched against host-heap
1900 // memory — the poisoned context failed the next test's module load.)
1901 //
1902 // We reject such a launch up front — before any driver launch state is
1903 // touched — so one guest cannot corrupt the context for others. Managed
1904 // pointers cost one cheap `cuPointerGetAttribute` query each. The
1905 // `docs/RISKS.md` "linear memory must be UVM-backed" constraint was
1906 // documented but unenforced; this is the enforcement.
1907 for arg in &lowered_args {
1908 if let LoweredArg::Ptr { host_ptr, .. } = arg {
1909 let mut is_managed: std::os::raw::c_int = 0;
1910 // SAFETY: `is_managed` is a valid, correctly-typed out-param for
1911 // the IS_MANAGED attribute (a 32-bit int). `host_ptr` was
1912 // bounds-checked into the guest's live linear memory by
1913 // `parse_argv`. `cuPointerGetAttribute` only reads driver
1914 // bookkeeping for the address — it never dereferences it.
1915 let res = unsafe {
1916 cuda_sys::cuPointerGetAttribute(
1917 &mut is_managed as *mut std::os::raw::c_int as *mut std::ffi::c_void,
1918 cuda_sys::CUpointer_attribute_enum::CU_POINTER_ATTRIBUTE_IS_MANAGED,
1919 *host_ptr as cuda_sys::CUdeviceptr,
1920 )
1921 };
1922 // Host-heap pointers are unknown to the driver and return
1923 // `CUDA_ERROR_INVALID_VALUE`; managed pointers return success
1924 // with `is_managed == 1`. Either non-success or `is_managed == 0`
1925 // means the address is not safe to launch against.
1926 if res != cuda_sys::CUresult::CUDA_SUCCESS || is_managed == 0 {
1927 caller.data().wasi_cuda().record_error(format!(
1928 "launch: kernel pointer argument is not GPU-addressable \
1929 (cuPointerGetAttribute IS_MANAGED -> {res:?}, \
1930 is_managed={is_managed}); refusing to launch so an invalid \
1931 device pointer cannot raise a sticky CUDA_ERROR_ILLEGAL_ADDRESS \
1932 that would poison the shared CUDA context for all tenants. Back \
1933 guest linear memory with the unified-memory MemoryCreator \
1934 (enable the `unified-memory` feature on tensor-wasm-mem)."
1935 ));
1936 return Err(AbiError::LaunchFailed);
1937 }
1938 }
1939 }
1940
1941 let launch_status = unsafe {
1942 cuda_sys::cuLaunchKernel(
1943 // cust 0.3.2 exposes the raw CUfunction handle via `to_raw()`
1944 // (NOT `as_raw()`); the spike originally guessed wrong.
1945 func.to_raw(),
1946 grid_x as u32,
1947 grid_y as u32,
1948 grid_z as u32,
1949 block_x as u32,
1950 block_y as u32,
1951 block_z as u32,
1952 shared_mem as u32,
1953 stream.as_inner(),
1954 kernel_params_ptr,
1955 std::ptr::null_mut(),
1956 )
1957 };
1958 if launch_status != cuda_sys::CUresult::CUDA_SUCCESS {
1959 caller.data().wasi_cuda().record_error(format!(
1960 "launch: cuLaunchKernel failed with status {launch_status:?}; \
1961 param_count={param_count}"
1962 ));
1963 return Err(AbiError::LaunchFailed);
1964 }
1965
1966 // Record an event on the stream so the dispatch future can poll
1967 // completion without holding a stream synchronize call open.
1968 let event = Event::new(EventFlags::DEFAULT).map_err(|e| {
1969 caller
1970 .data()
1971 .wasi_cuda()
1972 .record_error(format!("launch: Event::new failed: {e:?}"));
1973 AbiError::LaunchFailed
1974 })?;
1975 event.record(&stream).map_err(|e| {
1976 caller
1977 .data()
1978 .wasi_cuda()
1979 .record_error(format!("launch: event.record failed: {e:?}"));
1980 AbiError::LaunchFailed
1981 })?;
1982
1983 // Move the stream + event + arg storage into the blocking task so
1984 // synchronize doesn't block the wasmtime fiber. Stream + Event
1985 // are Send; `KernelParamStorage` has a Send impl that asserts
1986 // its raw pointers are not concurrently shared. Keep `handle`
1987 // (and therefore the Arc<Module>) alive until after synchronize
1988 // completes — the storage may carry raw host pointers into the
1989 // guest's linear memory which `cuLaunchKernel` has already
1990 // captured, but the closure keeps the backing alive in case
1991 // CUDA dereferences it again during sync.
1992 let handle_for_keepalive = handle.clone();
1993 let result = tokio::task::spawn_blocking(move || -> Result<(), String> {
1994 let _keep_event = event;
1995 let _keep_module = handle_for_keepalive;
1996 let _keep_storage = storage;
1997 // Fix #6: re-bind the primary context on this blocking-pool thread.
1998 // `spawn_blocking` runs the closure on a pool thread that may never
1999 // have made the context current, so `cuStreamSynchronize` would
2000 // otherwise fail with `CUDA_ERROR_INVALID_CONTEXT`.
2001 crate::cuda_ctx::ensure_current_context()?;
2002 stream
2003 .synchronize()
2004 .map_err(|e| format!("stream synchronize failed: {e:?}"))
2005 })
2006 .await
2007 .map_err(|_| {
2008 // JoinError — internal scheduler issue.
2009 AbiError::Internal
2010 })?;
2011 result.map_err(|e| {
2012 caller
2013 .data()
2014 .wasi_cuda()
2015 .record_error(format!("launch: {e}"));
2016 AbiError::LaunchFailed
2017 })?;
2018 // Stash the parsed args for observability before releasing the
2019 // handle. Tests inspect `last_lowered_args` to confirm the
2020 // marshalling round-trip held.
2021 *caller
2022 .data()
2023 .wasi_cuda()
2024 .last_lowered_args
2025 .lock()
2026 .unwrap_or_else(|e| e.into_inner()) = lowered_args;
2027 // Telemetry: a successful launch + synchronize counts as one
2028 // dispatched kernel for this instance.
2029 caller.data().wasi_cuda().record_kernel_launched();
2030 // `handle` is still in scope here; it (and the Arc<Module>) is
2031 // released by Drop now that synchronize has returned. The clone
2032 // moved into the blocking task may still hold the Arc briefly,
2033 // which is fine: the module stays alive until *all* clones drop.
2034 drop(handle);
2035 Ok(())
2036 }
2037
2038 #[cfg(not(feature = "cuda"))]
2039 {
2040 // Without CUDA we can't actually run the kernel; record the
2041 // parsed args (so tests can confirm the lowering held) and
2042 // surface `NotAvailable` so the Wasm caller knows the launch
2043 // did not run.
2044 let _ = handle; // suppress unused warning on no-CUDA.
2045 let parsed_count = lowered_args.len();
2046 *caller
2047 .data()
2048 .wasi_cuda()
2049 .last_lowered_args
2050 .lock()
2051 .unwrap_or_else(|e| e.into_inner()) = lowered_args;
2052 // Telemetry: the launch passed validation, acquired a permit, and
2053 // reached the dispatch path — count it as launched even though the
2054 // no-CUDA stub does not actually run the kernel. This mirrors the
2055 // CUDA happy path's bump so the metric reflects "launches dispatched"
2056 // consistently across feature configurations.
2057 caller.data().wasi_cuda().record_kernel_launched();
2058 caller.data().wasi_cuda().record_error(format!(
2059 "launch: CUDA not available on this host (argv parsed: {parsed_count} args)"
2060 ));
2061 Err(AbiError::NotAvailable)
2062 }
2063}
2064
2065fn sync_impl<T: HasWasiCuda>(_caller: &Caller<'_, T>) -> Result<(), AbiError> {
2066 let _span = info_span!(
2067 "wasi_cuda.sync",
2068 instance = %_caller.data().wasi_cuda().instance_id,
2069 )
2070 .entered();
2071 #[cfg(feature = "cuda")]
2072 {
2073 // Block on the current context's outstanding work. This is a
2074 // synchronous wasmtime function, so we can't await here; cust's
2075 // `CurrentContext::synchronize` is a blocking call that returns
2076 // once all queued work on the current context has finished.
2077 use cust::context::CurrentContext;
2078 // Fix #6: `synchronize` drains *this thread's current context*, so the
2079 // context must be current here. A `sync` call arriving on a thread
2080 // that never bound it would otherwise fail with
2081 // `CUDA_ERROR_INVALID_CONTEXT` (or drain the wrong context).
2082 if let Err(e) = crate::cuda_ctx::ensure_current_context() {
2083 _caller
2084 .data()
2085 .wasi_cuda()
2086 .record_error(format!("sync: CUDA context bind failed: {e}"));
2087 return Err(AbiError::LaunchFailed);
2088 }
2089 match CurrentContext::synchronize() {
2090 Ok(()) => Ok(()),
2091 Err(e) => {
2092 _caller
2093 .data()
2094 .wasi_cuda()
2095 .record_error(format!("sync: CurrentContext::synchronize failed: {e:?}"));
2096 Err(AbiError::LaunchFailed)
2097 }
2098 }
2099 }
2100 #[cfg(not(feature = "cuda"))]
2101 {
2102 // No outstanding GPU work on the no-CUDA path; sync is trivially complete.
2103 Ok(())
2104 }
2105}
2106
2107#[cfg(test)]
2108mod tests {
2109 use super::*;
2110 use crate::abi::FN_LAST_ERROR_PTR;
2111
2112 struct Dummy(WasiCudaContext);
2113 impl HasWasiCuda for Dummy {
2114 fn wasi_cuda(&self) -> &WasiCudaContext {
2115 &self.0
2116 }
2117 }
2118
2119 #[test]
2120 fn record_and_read_error() {
2121 let ctx = WasiCudaContext::new(InstanceId(42));
2122 ctx.record_error("oh no");
2123 assert_eq!(ctx.last_error().as_deref(), Some("oh no"));
2124 }
2125
2126 /// A lock poisoned by a panicking writer in another thread must NOT
2127 /// make the public `last_lowered_args()` accessor panic. `last_lowered_args`
2128 /// recovers via `.unwrap_or_else(|e| e.into_inner())`, so a poisoned
2129 /// `Mutex` yields the (possibly stale) inner `Vec` rather than an
2130 /// embedder-reachable panic. Regression guard for the MED finding that
2131 /// flagged the old `.expect("last_lowered_args poisoned")`.
2132 #[test]
2133 fn poisoned_lock_does_not_panic_last_lowered_args() {
2134 use std::sync::Arc;
2135
2136 let ctx = Arc::new(WasiCudaContext::new(InstanceId(7)));
2137
2138 // Poison the `last_lowered_args` mutex: take the lock in a child
2139 // thread and panic while holding it. `std::sync::Mutex` marks the
2140 // lock poisoned when the guard is dropped during unwinding.
2141 let poisoner = {
2142 let ctx = Arc::clone(&ctx);
2143 std::thread::spawn(move || {
2144 let _guard = ctx.last_lowered_args.lock().unwrap();
2145 panic!("poison the lock on purpose");
2146 })
2147 };
2148 // The child thread is expected to panic; swallow it.
2149 assert!(poisoner.join().is_err());
2150 assert!(ctx.last_lowered_args.is_poisoned());
2151
2152 // Public accessor must recover, not panic.
2153 let snapshot = ctx.last_lowered_args();
2154 assert!(snapshot.is_empty());
2155 }
2156
2157 #[test]
2158 fn add_to_linker_compiles() {
2159 let config = wasmtime::Config::new();
2160 let engine = wasmtime::Engine::new(&config).unwrap();
2161 let mut linker: Linker<Dummy> = Linker::new(&engine);
2162 add_to_linker(&mut linker).expect("add_to_linker");
2163 }
2164
2165 /// Confirms `add_to_linker` does NOT register `FN_LAST_ERROR_PTR` — that
2166 /// symbol is kept in `abi.rs` for ABI-compat but the host deliberately
2167 /// does not expose it. We verify by attempting `Linker::get` for the
2168 /// (module, function) pair; the host registers every other wasi-cuda
2169 /// function and skipping this one is intentional. We also confirm a
2170 /// guest that imports `FN_LAST_ERROR_PTR` fails to instantiate.
2171 #[tokio::test]
2172 async fn add_to_linker_does_not_register_last_error_ptr() {
2173 let config = wasmtime::Config::new();
2174 let engine = wasmtime::Engine::new(&config).unwrap();
2175 let mut linker: Linker<Dummy> = Linker::new(&engine);
2176 add_to_linker(&mut linker).expect("add_to_linker");
2177 // Build a tiny WAT importing the not-registered symbol and assert
2178 // instantiation fails because the linker has no matching export.
2179 let wat = format!(
2180 r#"
2181 (module
2182 (import "{MODULE}" "{fn_name}" (func (result i32)))
2183 )
2184 "#,
2185 fn_name = FN_LAST_ERROR_PTR
2186 );
2187 let bytes = wat::parse_str(&wat).unwrap();
2188 let module = wasmtime::Module::new(&engine, &bytes).expect("compile");
2189 let mut store = wasmtime::Store::new(&engine, Dummy(WasiCudaContext::new(InstanceId(101))));
2190 let result = linker.instantiate_async(&mut store, &module).await;
2191 assert!(
2192 result.is_err(),
2193 "instantiation must fail because FN_LAST_ERROR_PTR is not registered"
2194 );
2195 }
2196
2197 #[test]
2198 fn shared_back_pressure_constructor() {
2199 let bp = Arc::new(crate::async_dispatch::BackPressure::with_cap(8));
2200 let a = WasiCudaContext::with_back_pressure(InstanceId(1), bp.clone());
2201 let b = WasiCudaContext::with_back_pressure(InstanceId(2), bp.clone());
2202 assert_eq!(a.back_pressure().max_concurrent(), 8);
2203 assert_eq!(b.back_pressure().max_concurrent(), 8);
2204 // Confirm both contexts really share the same Arc<BackPressure>:
2205 assert!(Arc::ptr_eq(a.back_pressure(), b.back_pressure()));
2206 }
2207
2208 /// A well-formed, in-bounds `args` buffer whose first byte is an
2209 /// unknown tag must surface as `InvalidArgs` — distinct from
2210 /// `KernelArgsUnsupported` (reserved for size-cap fallbacks) and
2211 /// from `InvalidPointer` (reserved for OOB pointers). The 4
2212 /// zero-bytes the WAT writes parse as a leading 0x00 tag, which is
2213 /// not assigned. This is the v0.2 contract — see module docs and
2214 /// `docs/RISKS.md`.
2215 #[tokio::test]
2216 async fn launch_with_inbounds_unknown_tag_returns_invalid_args() {
2217 let config = wasmtime::Config::new();
2218 let engine = wasmtime::Engine::new(&config).unwrap();
2219 let mut linker: Linker<Dummy> = Linker::new(&engine);
2220 add_to_linker(&mut linker).expect("add_to_linker");
2221
2222 // Tiny module: one page of memory (64 KiB), and an exported
2223 // `try_launch` that hands the host (kernel_id=0, 1x1x1 grid,
2224 // 1x1x1 block, shared_mem=0, args_ptr=0, args_len=4). The 4-byte
2225 // region at offset 0 is in-bounds (all zero bytes), so the
2226 // bounds-check passes; the parser then sees a leading 0x00 tag
2227 // and rejects it as `InvalidArgs`.
2228 let wat = format!(
2229 r#"
2230 (module
2231 (import "{m}" "{fn_name}"
2232 (func $launch (param i64 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32)))
2233 (memory (export "memory") 1)
2234 (func (export "try_launch") (result i32)
2235 (call $launch
2236 (i64.const 0) ;; kernel_id
2237 (i32.const 1) (i32.const 1) (i32.const 1) ;; grid
2238 (i32.const 1) (i32.const 1) (i32.const 1) ;; block
2239 (i32.const 0) ;; shared_mem
2240 (i32.const 0) ;; args_ptr — inside the one-page region
2241 (i32.const 4) ;; args_len — 4 bytes, in-bounds
2242 ))
2243 )
2244 "#,
2245 m = MODULE,
2246 fn_name = FN_LAUNCH,
2247 );
2248 let bytes = wat::parse_str(&wat).unwrap();
2249 let module = wasmtime::Module::new(&engine, &bytes).expect("compile");
2250 let mut ctx = WasiCudaContext::new(InstanceId(202));
2251 ctx.enable_wasi_cuda();
2252 let mut store = wasmtime::Store::new(&engine, Dummy(ctx));
2253 let instance = linker
2254 .instantiate_async(&mut store, &module)
2255 .await
2256 .expect("instantiate");
2257 let try_launch = instance
2258 .get_typed_func::<(), i32>(&mut store, "try_launch")
2259 .expect("typed func");
2260 let rc = try_launch.call_async(&mut store, ()).await.expect("call");
2261 assert_eq!(
2262 rc,
2263 AbiError::InvalidArgs.code(),
2264 "unknown leading tag byte must return InvalidArgs ({}), \
2265 not KernelArgsUnsupported ({}) or InvalidPointer ({})",
2266 AbiError::InvalidArgs.code(),
2267 AbiError::KernelArgsUnsupported.code(),
2268 AbiError::InvalidPointer.code(),
2269 );
2270 // Confirm the recorded error message reports the parse failure.
2271 let last = store.data().wasi_cuda().last_error().unwrap_or_default();
2272 assert!(
2273 last.contains("kernel argv parse failed"),
2274 "expected argv-parse error, got: {last}"
2275 );
2276 }
2277
2278 /// An out-of-bounds args region must still return `InvalidPointer`
2279 /// — the bounds-check runs BEFORE the unsupported-args branch so a
2280 /// malicious guest cannot trade a `MemoryFault` (InvalidPointer) for
2281 /// the friendlier `KernelArgsUnsupported`.
2282 #[tokio::test]
2283 async fn launch_with_oob_args_returns_invalid_pointer() {
2284 let config = wasmtime::Config::new();
2285 let engine = wasmtime::Engine::new(&config).unwrap();
2286 let mut linker: Linker<Dummy> = Linker::new(&engine);
2287 add_to_linker(&mut linker).expect("add_to_linker");
2288
2289 // One page = 65536 bytes. args_ptr=70000 is well past the end,
2290 // so even `args_len=4` (4-byte read) is out of bounds.
2291 let wat = format!(
2292 r#"
2293 (module
2294 (import "{m}" "{fn_name}"
2295 (func $launch (param i64 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32)))
2296 (memory (export "memory") 1)
2297 (func (export "try_launch") (result i32)
2298 (call $launch
2299 (i64.const 0)
2300 (i32.const 1) (i32.const 1) (i32.const 1)
2301 (i32.const 1) (i32.const 1) (i32.const 1)
2302 (i32.const 0)
2303 (i32.const 70000) ;; args_ptr — past end of single page
2304 (i32.const 4) ;; args_len — would overshoot
2305 ))
2306 )
2307 "#,
2308 m = MODULE,
2309 fn_name = FN_LAUNCH,
2310 );
2311 let bytes = wat::parse_str(&wat).unwrap();
2312 let module = wasmtime::Module::new(&engine, &bytes).expect("compile");
2313 let mut ctx = WasiCudaContext::new(InstanceId(203));
2314 ctx.enable_wasi_cuda();
2315 let mut store = wasmtime::Store::new(&engine, Dummy(ctx));
2316 let instance = linker
2317 .instantiate_async(&mut store, &module)
2318 .await
2319 .expect("instantiate");
2320 let try_launch = instance
2321 .get_typed_func::<(), i32>(&mut store, "try_launch")
2322 .expect("typed func");
2323 let rc = try_launch.call_async(&mut store, ()).await.expect("call");
2324 assert_eq!(
2325 rc,
2326 AbiError::InvalidPointer.code(),
2327 "OOB args region must return InvalidPointer (memory fault), \
2328 not KernelArgsUnsupported — the bounds-check must run first"
2329 );
2330 }
2331
2332 /// A buffer larger than the kernel-args sanity cap surfaces as
2333 /// `KernelArgsUnsupported`. The cap is enforced by `parse_argv`
2334 /// before any per-record work — the host stub records the parse
2335 /// error in `last_error` and returns the negative code.
2336 ///
2337 /// We trigger this via a launch with `args_len` greater than
2338 /// `kernel_args::MAX_KERNEL_ARGS_BYTES` (the buffer itself is
2339 /// in-bounds because the WAT exports 16 pages = 1 MiB of linear
2340 /// memory).
2341 #[tokio::test]
2342 async fn launch_with_oversized_argv_returns_kernel_args_unsupported() {
2343 use crate::kernel_args::MAX_KERNEL_ARGS_BYTES;
2344
2345 let config = wasmtime::Config::new();
2346 let engine = wasmtime::Engine::new(&config).unwrap();
2347 let mut linker: Linker<Dummy> = Linker::new(&engine);
2348 add_to_linker(&mut linker).expect("add_to_linker");
2349
2350 let oversized = (MAX_KERNEL_ARGS_BYTES + 1) as i32;
2351 // 16 pages = 1 MiB, comfortably larger than the cap so the
2352 // outer bounds-check passes and the size-cap is what triggers.
2353 let wat = format!(
2354 r#"
2355 (module
2356 (import "{m}" "{fn_name}"
2357 (func $launch (param i64 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32)))
2358 (memory (export "memory") 16)
2359 (func (export "try_launch") (result i32)
2360 (call $launch
2361 (i64.const 0)
2362 (i32.const 1) (i32.const 1) (i32.const 1)
2363 (i32.const 1) (i32.const 1) (i32.const 1)
2364 (i32.const 0)
2365 (i32.const 0)
2366 (i32.const {oversized})))
2367 )
2368 "#,
2369 m = MODULE,
2370 fn_name = FN_LAUNCH,
2371 );
2372 let bytes = wat::parse_str(&wat).unwrap();
2373 let module = wasmtime::Module::new(&engine, &bytes).expect("compile");
2374 let mut ctx = WasiCudaContext::new(InstanceId(220));
2375 ctx.enable_wasi_cuda();
2376 let mut store = wasmtime::Store::new(&engine, Dummy(ctx));
2377 let instance = linker
2378 .instantiate_async(&mut store, &module)
2379 .await
2380 .expect("instantiate");
2381 let try_launch = instance
2382 .get_typed_func::<(), i32>(&mut store, "try_launch")
2383 .expect("typed func");
2384 let rc = try_launch.call_async(&mut store, ()).await.expect("call");
2385 assert_eq!(
2386 rc,
2387 AbiError::KernelArgsUnsupported.code(),
2388 "argv buffer past sanity cap must return KernelArgsUnsupported"
2389 );
2390 }
2391
2392 /// Capability gating: when `wasi_cuda_enabled` is `false` (the default
2393 /// for a brand-new context), every host function linked by
2394 /// [`add_to_linker`] must short-circuit with `AbiError::NotAvailable`
2395 /// — even otherwise-valid calls. This prevents a guest from gaining
2396 /// CUDA access just by importing the module.
2397 ///
2398 /// Post-e4e30b6 the disabled-capability paths deliberately do NOT
2399 /// `record_error`: a recorded message would (a) be readable by the
2400 /// guest via `last_error_*` if the embedder ever flipped the
2401 /// capability back on, turning the gate into a leak channel, and
2402 /// (b) burn allocations + mutex traffic for a hostile guest that
2403 /// hammers disabled calls. The `NotAvailable` return code is the
2404 /// only signal; this test asserts that contract.
2405 #[tokio::test]
2406 async fn launch_without_capability_returns_not_available() {
2407 let config = wasmtime::Config::new();
2408 let engine = wasmtime::Engine::new(&config).unwrap();
2409 let mut linker: Linker<Dummy> = Linker::new(&engine);
2410 add_to_linker(&mut linker).expect("add_to_linker");
2411
2412 // A trivially-valid launch: 1x1x1 grid + block, zero args. With the
2413 // capability enabled this would either succeed (CUDA) or return
2414 // `NotAvailable` from the no-CUDA branch *after* full validation;
2415 // with the capability disabled we expect `NotAvailable` directly
2416 // from the linker wrapper, *before* any validation work.
2417 //
2418 // We also import `last_error_len` and call it after the rejected
2419 // launch: on a disabled-capability context that surface returns
2420 // `AbiError::NotAvailable.code()` (the "surface unavailable on
2421 // this instance" sentinel) — NOT a positive length, because no
2422 // error must have been recorded.
2423 let wat = format!(
2424 r#"
2425 (module
2426 (import "{m}" "{fn_launch}"
2427 (func $launch (param i64 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32)))
2428 (import "{m}" "{fn_last_err_len}"
2429 (func $last_error_len (result i32)))
2430 (memory (export "memory") 1)
2431 (func (export "try_launch") (result i32)
2432 (call $launch
2433 (i64.const 1)
2434 (i32.const 1) (i32.const 1) (i32.const 1)
2435 (i32.const 1) (i32.const 1) (i32.const 1)
2436 (i32.const 0) (i32.const 0) (i32.const 0)))
2437 (func (export "probe_last_error_len") (result i32)
2438 (call $last_error_len))
2439 )
2440 "#,
2441 m = MODULE,
2442 fn_launch = FN_LAUNCH,
2443 fn_last_err_len = FN_LAST_ERROR_LEN,
2444 );
2445 let bytes = wat::parse_str(&wat).unwrap();
2446 let module = wasmtime::Module::new(&engine, &bytes).expect("compile");
2447 // Note: we deliberately do NOT call `enable_wasi_cuda()`.
2448 let ctx = WasiCudaContext::new(InstanceId(901));
2449 assert!(
2450 !ctx.wasi_cuda_enabled(),
2451 "freshly-constructed context must default to disabled"
2452 );
2453 let mut store = wasmtime::Store::new(&engine, Dummy(ctx));
2454 let instance = linker
2455 .instantiate_async(&mut store, &module)
2456 .await
2457 .expect("instantiate");
2458 let try_launch = instance
2459 .get_typed_func::<(), i32>(&mut store, "try_launch")
2460 .expect("typed func");
2461 let rc = try_launch.call_async(&mut store, ()).await.expect("call");
2462 assert_eq!(
2463 rc,
2464 AbiError::NotAvailable.code(),
2465 "ungranted wasi-cuda capability must surface as NotAvailable, \
2466 got {rc}"
2467 );
2468 // No error message must have been recorded — recording one would
2469 // leak through last_error_* if the embedder ever flipped the
2470 // capability back on (see the doc comment above and the rationale
2471 // in host.rs lines 360-368 / 393-398 / 419-422 / 436-444 / 458-462).
2472 assert!(
2473 store.data().wasi_cuda().last_error().is_none(),
2474 "disabled-capability path must NOT record_error, but found: {:?}",
2475 store.data().wasi_cuda().last_error()
2476 );
2477 // Calling last_error_len through the real ABI surface on a
2478 // disabled context returns `NotAvailable.code()` (i.e. -1) — the
2479 // documented "this surface is unavailable on this instance"
2480 // sentinel. Crucially this is NOT `0` (which would mean "no error
2481 // on a gate-passing context") and NOT a positive length (which
2482 // would mean an error was recorded, leaking the gate state).
2483 let probe = instance
2484 .get_typed_func::<(), i32>(&mut store, "probe_last_error_len")
2485 .expect("typed func");
2486 let len_rc = probe.call_async(&mut store, ()).await.expect("call");
2487 assert_eq!(
2488 len_rc,
2489 AbiError::NotAvailable.code(),
2490 "last_error_len on a disabled-capability context must return \
2491 the NotAvailable sentinel ({}), got {len_rc}",
2492 AbiError::NotAvailable.code()
2493 );
2494 }
2495
2496 /// Once the capability is granted on the same context the launch
2497 /// proceeds through validation and reaches the launch dispatch path
2498 /// (which on no-CUDA hosts ultimately also returns `NotAvailable`,
2499 /// but only *after* validation runs — confirming the gate flipped).
2500 #[tokio::test]
2501 async fn launch_with_capability_passes_gate() {
2502 let config = wasmtime::Config::new();
2503 let engine = wasmtime::Engine::new(&config).unwrap();
2504 let mut linker: Linker<Dummy> = Linker::new(&engine);
2505 add_to_linker(&mut linker).expect("add_to_linker");
2506
2507 // Use a bogus kernel id (999): with the capability enabled
2508 // validation passes the dimension caps and reaches the kernel
2509 // lookup, which returns `InvalidKernel`. Without the capability
2510 // we'd see `NotAvailable` instead — the cross-check that
2511 // confirms `enable_wasi_cuda` actually flips behaviour.
2512 let wat = format!(
2513 r#"
2514 (module
2515 (import "{m}" "{fn_name}"
2516 (func $launch (param i64 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32)))
2517 (memory (export "memory") 1)
2518 (func (export "try_launch") (result i32)
2519 (call $launch
2520 (i64.const 999)
2521 (i32.const 1) (i32.const 1) (i32.const 1)
2522 (i32.const 1) (i32.const 1) (i32.const 1)
2523 (i32.const 0) (i32.const 0) (i32.const 0)))
2524 )
2525 "#,
2526 m = MODULE,
2527 fn_name = FN_LAUNCH,
2528 );
2529 let bytes = wat::parse_str(&wat).unwrap();
2530 let module = wasmtime::Module::new(&engine, &bytes).expect("compile");
2531 let mut ctx = WasiCudaContext::new(InstanceId(902));
2532 ctx.enable_wasi_cuda();
2533 assert!(
2534 ctx.wasi_cuda_enabled(),
2535 "enable_wasi_cuda() must flip the flag"
2536 );
2537 let mut store = wasmtime::Store::new(&engine, Dummy(ctx));
2538 let instance = linker
2539 .instantiate_async(&mut store, &module)
2540 .await
2541 .expect("instantiate");
2542 let try_launch = instance
2543 .get_typed_func::<(), i32>(&mut store, "try_launch")
2544 .expect("typed func");
2545 let rc = try_launch.call_async(&mut store, ()).await.expect("call");
2546 assert_eq!(
2547 rc,
2548 AbiError::InvalidKernel.code(),
2549 "with capability granted, an unknown kernel id must reach the \
2550 registry lookup and return InvalidKernel; got {rc}"
2551 );
2552 }
2553
2554 /// `KernelRegistry::lookup` returns a `KernelHandle` whose lifetime is
2555 /// independent of the underlying dashmap entry — `remove` does not
2556 /// invalidate a previously returned handle. This is the property that
2557 /// fixes the original UAF.
2558 #[test]
2559 fn registry_lookup_handle_outlives_remove() {
2560 let reg = KernelRegistry::new();
2561 let id = reg
2562 .register(KernelEntry {
2563 owner: InstanceId(7),
2564 entry: "k".into(),
2565 ptx_bytes_len: 16,
2566 #[cfg(feature = "cuda")]
2567 module: None,
2568 })
2569 .unwrap();
2570 let handle = reg.lookup(id, InstanceId(7)).unwrap();
2571 assert!(reg.remove(id).is_some());
2572 // handle still readable; no UAF possible.
2573 assert_eq!(handle.entry, "k");
2574 }
2575
2576 /// MEDIUM finding regression guard: a `launch` whose `shared_mem`
2577 /// exceeds [`MAX_DYNAMIC_SHARED_MEM_BYTES`] must be rejected host-side
2578 /// with `InvalidDimensions` — before any driver call — rather than
2579 /// forwarded to `cuLaunchKernel`. We enable the capability and use a
2580 /// 1x1x1 grid + block so the only validation failure is the shared-mem
2581 /// cap.
2582 #[tokio::test]
2583 async fn launch_with_oversize_shared_mem_returns_invalid_dimensions() {
2584 let config = wasmtime::Config::new();
2585 let engine = wasmtime::Engine::new(&config).unwrap();
2586 let mut linker: Linker<Dummy> = Linker::new(&engine);
2587 add_to_linker(&mut linker).expect("add_to_linker");
2588
2589 let oversize = MAX_DYNAMIC_SHARED_MEM_BYTES + 1;
2590 let wat = format!(
2591 r#"
2592 (module
2593 (import "{m}" "{fn_name}"
2594 (func $launch (param i64 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32)))
2595 (memory (export "memory") 1)
2596 (func (export "try_launch") (result i32)
2597 (call $launch
2598 (i64.const 0)
2599 (i32.const 1) (i32.const 1) (i32.const 1)
2600 (i32.const 1) (i32.const 1) (i32.const 1)
2601 (i32.const {oversize}) ;; shared_mem — above the host cap
2602 (i32.const 0) (i32.const 0)))
2603 )
2604 "#,
2605 m = MODULE,
2606 fn_name = FN_LAUNCH,
2607 );
2608 let bytes = wat::parse_str(&wat).unwrap();
2609 let module = wasmtime::Module::new(&engine, &bytes).expect("compile");
2610 let mut ctx = WasiCudaContext::new(InstanceId(230));
2611 ctx.enable_wasi_cuda();
2612 let mut store = wasmtime::Store::new(&engine, Dummy(ctx));
2613 let instance = linker
2614 .instantiate_async(&mut store, &module)
2615 .await
2616 .expect("instantiate");
2617 let try_launch = instance
2618 .get_typed_func::<(), i32>(&mut store, "try_launch")
2619 .expect("typed func");
2620 let rc = try_launch.call_async(&mut store, ()).await.expect("call");
2621 assert_eq!(
2622 rc,
2623 AbiError::InvalidDimensions.code(),
2624 "shared_mem past the host cap must return InvalidDimensions, got {rc}"
2625 );
2626 let last = store.data().wasi_cuda().last_error().unwrap_or_default();
2627 assert!(
2628 last.contains("MAX_DYNAMIC_SHARED_MEM_BYTES"),
2629 "expected shared-mem cap error, got: {last}"
2630 );
2631 }
2632
2633 /// LOW finding regression guard: a negative `ptx_len` must surface as
2634 /// `InvalidPointer` (the memory-region error `read_bytes` would yield)
2635 /// rather than `QuotaExceeded` — a negative i32 cast through `as usize`
2636 /// would otherwise trip the `MAX_PTX_BYTES` branch and misreport the
2637 /// failure. We invoke `load_ptx` directly through the linked host
2638 /// surface with `ptx_len = -1`.
2639 #[tokio::test]
2640 async fn load_ptx_negative_ptx_len_returns_invalid_pointer() {
2641 let config = wasmtime::Config::new();
2642 let engine = wasmtime::Engine::new(&config).unwrap();
2643 let mut linker: Linker<Dummy> = Linker::new(&engine);
2644 add_to_linker(&mut linker).expect("add_to_linker");
2645
2646 let wat = format!(
2647 r#"
2648 (module
2649 (import "{m}" "{fn_name}"
2650 (func $load_ptx (param i32 i32 i32 i32) (result i64)))
2651 (memory (export "memory") 1)
2652 (func (export "try_load") (result i64)
2653 (call $load_ptx
2654 (i32.const 0) ;; ptx_ptr
2655 (i32.const -1) ;; ptx_len — negative
2656 (i32.const 0) ;; entry_ptr
2657 (i32.const 4))) ;; entry_len
2658 )
2659 "#,
2660 m = MODULE,
2661 fn_name = FN_LOAD_PTX,
2662 );
2663 let bytes = wat::parse_str(&wat).unwrap();
2664 let module = wasmtime::Module::new(&engine, &bytes).expect("compile");
2665 let mut ctx = WasiCudaContext::new(InstanceId(231));
2666 ctx.enable_wasi_cuda();
2667 let mut store = wasmtime::Store::new(&engine, Dummy(ctx));
2668 let instance = linker
2669 .instantiate_async(&mut store, &module)
2670 .await
2671 .expect("instantiate");
2672 let try_load = instance
2673 .get_typed_func::<(), i64>(&mut store, "try_load")
2674 .expect("typed func");
2675 let rc = try_load.call_async(&mut store, ()).await.expect("call");
2676 assert_eq!(
2677 rc,
2678 AbiError::InvalidPointer.code() as i64,
2679 "negative ptx_len must return InvalidPointer ({}), not QuotaExceeded ({})",
2680 AbiError::InvalidPointer.code(),
2681 AbiError::QuotaExceeded.code(),
2682 );
2683 let last = store.data().wasi_cuda().last_error().unwrap_or_default();
2684 assert!(
2685 last.contains("negative ptx_len"),
2686 "expected negative-ptx_len error, got: {last}"
2687 );
2688 }
2689
2690 // ----------------------------------------------------------------
2691 // Explicit device-memory host functions (no-CUDA path).
2692 // ----------------------------------------------------------------
2693
2694 /// WAT exposing the four device-memory host functions plus an `alloc`
2695 /// that splits a `u64` size into `(lo, hi)`. Each exported wrapper
2696 /// returns the raw ABI code so tests can assert on it.
2697 fn device_mem_wat() -> String {
2698 format!(
2699 r#"
2700 (module
2701 (import "{m}" "{fn_alloc}"
2702 (func $alloc (param i32 i32) (result i64)))
2703 (import "{m}" "{fn_free}"
2704 (func $free (param i32 i32) (result i32)))
2705 (import "{m}" "{fn_h2d}"
2706 (func $h2d (param i32 i32 i32 i32) (result i32)))
2707 (import "{m}" "{fn_d2h}"
2708 (func $d2h (param i32 i32 i32 i32) (result i32)))
2709 (memory (export "memory") 1)
2710 ;; alloc(size_lo, size_hi) -> i64
2711 (func (export "do_alloc") (param i32 i32) (result i64)
2712 (call $alloc (local.get 0) (local.get 1)))
2713 ;; free(handle_lo, handle_hi) -> i32
2714 (func (export "do_free") (param i32 i32) (result i32)
2715 (call $free (local.get 0) (local.get 1)))
2716 ;; memcpy_h2d(handle_lo, handle_hi, src_ptr, len) -> i32
2717 (func (export "do_h2d") (param i32 i32 i32 i32) (result i32)
2718 (call $h2d (local.get 0) (local.get 1) (local.get 2) (local.get 3)))
2719 ;; memcpy_d2h(dst_ptr, handle_lo, handle_hi, len) -> i32
2720 (func (export "do_d2h") (param i32 i32 i32 i32) (result i32)
2721 (call $d2h (local.get 0) (local.get 1) (local.get 2) (local.get 3)))
2722 )
2723 "#,
2724 m = MODULE,
2725 fn_alloc = FN_ALLOC,
2726 fn_free = FN_FREE,
2727 fn_h2d = FN_MEMCPY_H2D,
2728 fn_d2h = FN_MEMCPY_D2H,
2729 )
2730 }
2731
2732 /// `memcpy-h2d` with an out-of-bounds source region must return
2733 /// `InvalidPointer` — the bounds-check runs before any (no-op on this
2734 /// path) driver work. We pre-seed a tracked handle on the registry so
2735 /// the handle lookup succeeds and the failure is attributable to the
2736 /// source region, not the handle.
2737 #[tokio::test]
2738 async fn memcpy_h2d_oob_source_returns_invalid_pointer() {
2739 let config = wasmtime::Config::new();
2740 let engine = wasmtime::Engine::new(&config).unwrap();
2741 let mut linker: Linker<Dummy> = Linker::new(&engine);
2742 add_to_linker(&mut linker).expect("add_to_linker");
2743
2744 let mut ctx = WasiCudaContext::new(InstanceId(300));
2745 ctx.enable_wasi_cuda();
2746 // Pre-seed a 1 MiB device buffer owned by this instance so the
2747 // handle lookup inside memcpy succeeds.
2748 let handle = ctx
2749 .device_mem()
2750 .insert(crate::device_mem::DeviceMemEntry {
2751 owner: InstanceId(300),
2752 size: 1024 * 1024,
2753 #[cfg(feature = "cuda")]
2754 device_ptr: 0,
2755 })
2756 .expect("insert");
2757
2758 let module = wasmtime::Module::new(&engine, wat::parse_str(device_mem_wat()).unwrap())
2759 .expect("compile");
2760 let mut store = wasmtime::Store::new(&engine, Dummy(ctx));
2761 let instance = linker
2762 .instantiate_async(&mut store, &module)
2763 .await
2764 .expect("instantiate");
2765 let h2d = instance
2766 .get_typed_func::<(i32, i32, i32, i32), i32>(&mut store, "do_h2d")
2767 .expect("typed func");
2768 // src_ptr = 70000 is past the single 64 KiB page; len = 16 still in
2769 // the buffer's size budget but the source region is OOB.
2770 let rc = h2d
2771 .call_async(&mut store, (handle as i32, 0, 70000, 16))
2772 .await
2773 .expect("call");
2774 assert_eq!(
2775 rc,
2776 AbiError::InvalidPointer.code(),
2777 "OOB source region must return InvalidPointer, got {rc}"
2778 );
2779 }
2780
2781 /// `memcpy-h2d` with `len` larger than the device buffer's allocated
2782 /// size returns `InvalidArgs` — a structural argument error distinct
2783 /// from a memory fault.
2784 #[tokio::test]
2785 async fn memcpy_h2d_oversize_len_returns_invalid_args() {
2786 let config = wasmtime::Config::new();
2787 let engine = wasmtime::Engine::new(&config).unwrap();
2788 let mut linker: Linker<Dummy> = Linker::new(&engine);
2789 add_to_linker(&mut linker).expect("add_to_linker");
2790
2791 let mut ctx = WasiCudaContext::new(InstanceId(301));
2792 ctx.enable_wasi_cuda();
2793 // Buffer is only 8 bytes; a 16-byte copy overruns it.
2794 let handle = ctx
2795 .device_mem()
2796 .insert(crate::device_mem::DeviceMemEntry {
2797 owner: InstanceId(301),
2798 size: 8,
2799 #[cfg(feature = "cuda")]
2800 device_ptr: 0,
2801 })
2802 .expect("insert");
2803
2804 let module = wasmtime::Module::new(&engine, wat::parse_str(device_mem_wat()).unwrap())
2805 .expect("compile");
2806 let mut store = wasmtime::Store::new(&engine, Dummy(ctx));
2807 let instance = linker
2808 .instantiate_async(&mut store, &module)
2809 .await
2810 .expect("instantiate");
2811 let h2d = instance
2812 .get_typed_func::<(i32, i32, i32, i32), i32>(&mut store, "do_h2d")
2813 .expect("typed func");
2814 let rc = h2d
2815 .call_async(&mut store, (handle as i32, 0, 0, 16))
2816 .await
2817 .expect("call");
2818 assert_eq!(
2819 rc,
2820 AbiError::InvalidArgs.code(),
2821 "len > buffer size must return InvalidArgs, got {rc}"
2822 );
2823 }
2824
2825 /// A guest cannot operate on a handle owned by another instance:
2826 /// `free` / `memcpy-h2d` / `memcpy-d2h` on a cross-owner handle all
2827 /// return `InvalidHandle`. The handle is seeded under a *different*
2828 /// `InstanceId` than the running context.
2829 #[tokio::test]
2830 async fn device_mem_cross_owner_handle_rejected() {
2831 let config = wasmtime::Config::new();
2832 let engine = wasmtime::Engine::new(&config).unwrap();
2833 let mut linker: Linker<Dummy> = Linker::new(&engine);
2834 add_to_linker(&mut linker).expect("add_to_linker");
2835
2836 let mut ctx = WasiCudaContext::new(InstanceId(302));
2837 ctx.enable_wasi_cuda();
2838 // Seed a handle owned by a *different* instance (999). The running
2839 // context (302) must not be able to free / copy it.
2840 let foreign = ctx
2841 .device_mem()
2842 .insert(crate::device_mem::DeviceMemEntry {
2843 owner: InstanceId(999),
2844 size: 4096,
2845 #[cfg(feature = "cuda")]
2846 device_ptr: 0,
2847 })
2848 .expect("insert");
2849
2850 let module = wasmtime::Module::new(&engine, wat::parse_str(device_mem_wat()).unwrap())
2851 .expect("compile");
2852 let mut store = wasmtime::Store::new(&engine, Dummy(ctx));
2853 let instance = linker
2854 .instantiate_async(&mut store, &module)
2855 .await
2856 .expect("instantiate");
2857
2858 let free = instance
2859 .get_typed_func::<(i32, i32), i32>(&mut store, "do_free")
2860 .expect("typed func");
2861 let rc = free
2862 .call_async(&mut store, (foreign as i32, 0))
2863 .await
2864 .expect("call");
2865 assert_eq!(
2866 rc,
2867 AbiError::InvalidHandle.code(),
2868 "cross-owner free must return InvalidHandle, got {rc}"
2869 );
2870
2871 let h2d = instance
2872 .get_typed_func::<(i32, i32, i32, i32), i32>(&mut store, "do_h2d")
2873 .expect("typed func");
2874 let rc = h2d
2875 .call_async(&mut store, (foreign as i32, 0, 0, 16))
2876 .await
2877 .expect("call");
2878 assert_eq!(
2879 rc,
2880 AbiError::InvalidHandle.code(),
2881 "cross-owner memcpy_h2d must return InvalidHandle, got {rc}"
2882 );
2883
2884 let d2h = instance
2885 .get_typed_func::<(i32, i32, i32, i32), i32>(&mut store, "do_d2h")
2886 .expect("typed func");
2887 let rc = d2h
2888 .call_async(&mut store, (0, foreign as i32, 0, 16))
2889 .await
2890 .expect("call");
2891 assert_eq!(
2892 rc,
2893 AbiError::InvalidHandle.code(),
2894 "cross-owner memcpy_d2h must return InvalidHandle, got {rc}"
2895 );
2896 // The foreign handle is still present and still owned by 999.
2897 assert!(store
2898 .data()
2899 .wasi_cuda()
2900 .device_mem()
2901 .lookup(foreign, InstanceId(999))
2902 .is_ok());
2903 }
2904
2905 /// `alloc` of zero bytes is a structural error (`InvalidArgs`); an
2906 /// oversize request trips the per-call cap (`QuotaExceeded`). Both are
2907 /// rejected before the no-CUDA `NotAvailable` stub.
2908 #[tokio::test]
2909 async fn alloc_rejects_zero_and_oversize() {
2910 let config = wasmtime::Config::new();
2911 let engine = wasmtime::Engine::new(&config).unwrap();
2912 let mut linker: Linker<Dummy> = Linker::new(&engine);
2913 add_to_linker(&mut linker).expect("add_to_linker");
2914
2915 let mut ctx = WasiCudaContext::new(InstanceId(303));
2916 ctx.enable_wasi_cuda();
2917 let module = wasmtime::Module::new(&engine, wat::parse_str(device_mem_wat()).unwrap())
2918 .expect("compile");
2919 let mut store = wasmtime::Store::new(&engine, Dummy(ctx));
2920 let instance = linker
2921 .instantiate_async(&mut store, &module)
2922 .await
2923 .expect("instantiate");
2924 let alloc = instance
2925 .get_typed_func::<(i32, i32), i64>(&mut store, "do_alloc")
2926 .expect("typed func");
2927
2928 // size = 0
2929 let rc = alloc.call_async(&mut store, (0, 0)).await.expect("call");
2930 assert_eq!(
2931 rc,
2932 AbiError::InvalidArgs.code() as i64,
2933 "zero-size alloc must return InvalidArgs, got {rc}"
2934 );
2935
2936 // size = MAX_DEVICE_ALLOC_BYTES + 1 (split into lo/hi).
2937 let oversize = crate::device_mem::MAX_DEVICE_ALLOC_BYTES + 1;
2938 let lo = (oversize & 0xffff_ffff) as i32;
2939 let hi = (oversize >> 32) as i32;
2940 let rc = alloc.call_async(&mut store, (lo, hi)).await.expect("call");
2941 assert_eq!(
2942 rc,
2943 AbiError::QuotaExceeded.code() as i64,
2944 "oversize alloc must return QuotaExceeded, got {rc}"
2945 );
2946 }
2947
2948 /// `alloc` then `free` lifecycle, asserting the real per-feature return
2949 /// contract (BUG-4).
2950 ///
2951 /// On the no-CUDA path `alloc` returns `NotAvailable` (like the launch
2952 /// stub) but still tracks the handle in the registry; the guest can then
2953 /// `free` it. Under `--features cuda` on real hardware the device alloc
2954 /// SUCCEEDS, so the wire return is the non-negative device handle (not the
2955 /// `NotAvailable` error code). In both configs the handle is tracked,
2956 /// `free` succeeds, the aggregate device-bytes gauge returns to zero, and a
2957 /// second free of the same handle fails with `InvalidHandle` (double-free
2958 /// protection).
2959 #[tokio::test]
2960 async fn alloc_tracks_handle_then_free_lifecycle() {
2961 // Under `--features cuda` the real `cuMemAlloc`/`cuMemFree` paths run.
2962 // We deliberately do NOT bind a CUDA context here: `alloc_impl` /
2963 // `free_impl` now bind device 0's primary context themselves (mirroring
2964 // `sync_impl`). `#[tokio::test]` uses the current-thread runtime, so the
2965 // wasm calls below execute on THIS thread and the host fns' self-bind
2966 // takes effect. This makes the test double as a regression guard — if
2967 // the host-fn self-bind is removed, the alloc returns `LaunchFailed`
2968 // (no current context) and this test fails.
2969 let config = wasmtime::Config::new();
2970 let engine = wasmtime::Engine::new(&config).unwrap();
2971 let mut linker: Linker<Dummy> = Linker::new(&engine);
2972 add_to_linker(&mut linker).expect("add_to_linker");
2973
2974 let mut ctx = WasiCudaContext::new(InstanceId(304));
2975 ctx.enable_wasi_cuda();
2976 let module = wasmtime::Module::new(&engine, wat::parse_str(device_mem_wat()).unwrap())
2977 .expect("compile");
2978 let mut store = wasmtime::Store::new(&engine, Dummy(ctx));
2979 let instance = linker
2980 .instantiate_async(&mut store, &module)
2981 .await
2982 .expect("instantiate");
2983 let alloc = instance
2984 .get_typed_func::<(i32, i32), i64>(&mut store, "do_alloc")
2985 .expect("typed func");
2986 let free = instance
2987 .get_typed_func::<(i32, i32), i32>(&mut store, "do_free")
2988 .expect("typed func");
2989
2990 // alloc 4096 bytes. The return-code contract differs by feature:
2991 // * no-CUDA: the stub returns `NotAvailable` *after* tracking the
2992 // handle (mirroring the launch stub).
2993 // * cuda: the real `cuMemAlloc` runs and `alloc` returns the
2994 // non-negative device handle the registry assigned.
2995 // Either way the handle is tracked (one live 4096-byte allocation) and
2996 // freeable below.
2997 let rc = alloc.call_async(&mut store, (4096, 0)).await.expect("call");
2998
2999 #[cfg(not(feature = "cuda"))]
3000 assert_eq!(
3001 rc,
3002 AbiError::NotAvailable.code() as i64,
3003 "no-CUDA alloc must return NotAvailable, got {rc}"
3004 );
3005
3006 #[cfg(feature = "cuda")]
3007 {
3008 // On real hardware the device alloc succeeds: the wire return is
3009 // the assigned handle, which is non-negative (AbiError codes are
3010 // all negative) and, as the first allocation, equals 1.
3011 assert_eq!(
3012 rc, 1,
3013 "first allocation's handle id is 1 (registry hands out \
3014 sequential ids from 1); a negative rc here means the device \
3015 alloc failed (e.g. no current CUDA context), got {rc}"
3016 );
3017 }
3018
3019 // The handle was tracked: exactly one live allocation of 4096.
3020 assert_eq!(store.data().wasi_cuda().device_mem().len(), 1);
3021 assert_eq!(
3022 store.data().wasi_cuda().device_mem().total_device_bytes(),
3023 4096
3024 );
3025 // The handle id is 1 (registry hands out sequential ids from 1).
3026 let handle: u64 = 1;
3027 let rc = free
3028 .call_async(&mut store, (handle as i32, 0))
3029 .await
3030 .expect("call");
3031 assert_eq!(rc, 0, "free of a tracked handle must succeed, got {rc}");
3032 assert!(store.data().wasi_cuda().device_mem().is_empty());
3033 assert_eq!(
3034 store.data().wasi_cuda().device_mem().total_device_bytes(),
3035 0
3036 );
3037 // Double-free fails.
3038 let rc = free
3039 .call_async(&mut store, (handle as i32, 0))
3040 .await
3041 .expect("call");
3042 assert_eq!(
3043 rc,
3044 AbiError::InvalidHandle.code(),
3045 "double-free must return InvalidHandle, got {rc}"
3046 );
3047 }
3048
3049 /// The aggregate device-bytes cap is enforced by the registry that the
3050 /// `alloc` host fn writes through: inserting buffers totalling
3051 /// `MAX_TOTAL_DEVICE_BYTES` succeeds; one more trips `QuotaExceeded`.
3052 ///
3053 /// Uses an isolated [`DeviceMemBudget`] (rather than the process-wide
3054 /// singleton a real context shares) so this bulk-insert-without-free test
3055 /// neither leaks into nor is perturbed by the shared budget other tests in
3056 /// this process touch — the per-instance cap is what we're exercising here.
3057 #[test]
3058 fn device_mem_aggregate_cap_via_registry() {
3059 use crate::device_mem::{
3060 DeviceMemBudget, DeviceMemEntry, DeviceMemRegistry, MAX_DEVICE_ALLOC_BYTES,
3061 MAX_TOTAL_DEVICE_BYTES,
3062 };
3063 let reg = DeviceMemRegistry::with_budget(Arc::new(DeviceMemBudget::new()));
3064 let per = MAX_DEVICE_ALLOC_BYTES;
3065 let count = (MAX_TOTAL_DEVICE_BYTES / per) as usize;
3066 for _ in 0..count {
3067 reg.insert(DeviceMemEntry {
3068 owner: InstanceId(305),
3069 size: per,
3070 #[cfg(feature = "cuda")]
3071 device_ptr: 0,
3072 })
3073 .expect("under cap");
3074 }
3075 assert_eq!(
3076 reg.insert(DeviceMemEntry {
3077 owner: InstanceId(305),
3078 size: per,
3079 #[cfg(feature = "cuda")]
3080 device_ptr: 0,
3081 })
3082 .unwrap_err(),
3083 AbiError::QuotaExceeded
3084 );
3085 }
3086
3087 // ----------------------------------------------------------------
3088 // Per-instance metrics snapshot.
3089 // ----------------------------------------------------------------
3090
3091 /// A fresh context reports an all-zero snapshot.
3092 #[test]
3093 fn metrics_snapshot_zero_on_fresh_context() {
3094 let ctx = WasiCudaContext::new(InstanceId(400));
3095 let snap = ctx.metrics_snapshot();
3096 assert_eq!(snap, InstanceMetricsSnapshot::default());
3097 assert_eq!(snap.kernels_launched, 0);
3098 assert_eq!(snap.bytes_pinned, 0);
3099 assert_eq!(snap.back_pressure_rejections, 0);
3100 assert_eq!(snap.yield_count, 0);
3101 assert_eq!(snap.device_bytes_allocated, 0);
3102 }
3103
3104 /// The snapshot reflects recorded activity: a registered kernel bumps
3105 /// `bytes_pinned`, a tracked device buffer bumps
3106 /// `device_bytes_allocated`, the internal counters bump
3107 /// `kernels_launched` / `back_pressure_rejections`, and folding in a
3108 /// scheduler surfaces its `yield_count`.
3109 #[test]
3110 fn metrics_snapshot_reflects_activity() {
3111 let ctx = WasiCudaContext::new(InstanceId(401));
3112
3113 // Register a kernel → bytes_pinned.
3114 ctx.registry
3115 .register(KernelEntry {
3116 owner: InstanceId(401),
3117 entry: "k".into(),
3118 ptx_bytes_len: 2048,
3119 #[cfg(feature = "cuda")]
3120 module: None,
3121 })
3122 .expect("register");
3123
3124 // Track a device buffer → device_bytes_allocated.
3125 ctx.device_mem()
3126 .insert(crate::device_mem::DeviceMemEntry {
3127 owner: InstanceId(401),
3128 size: 65536,
3129 #[cfg(feature = "cuda")]
3130 device_ptr: 0,
3131 })
3132 .expect("insert");
3133
3134 // Bump the lifetime counters directly (the launch path does this
3135 // through the private helpers; here we exercise the read surface).
3136 ctx.record_kernel_launched();
3137 ctx.record_kernel_launched();
3138 ctx.record_back_pressure_rejection();
3139
3140 let snap = ctx.metrics_snapshot();
3141 assert_eq!(snap.kernels_launched, 2);
3142 assert_eq!(snap.bytes_pinned, 2048);
3143 assert_eq!(snap.back_pressure_rejections, 1);
3144 assert_eq!(snap.device_bytes_allocated, 65536);
3145 assert_eq!(snap.yield_count, 0, "no scheduler folded in yet");
3146
3147 // Fold in a scheduler whose yield counter has advanced.
3148 let sched = SchedulerContext::unbounded();
3149 sched.yield_now();
3150 sched.yield_now();
3151 sched.yield_now();
3152 let snap = ctx.metrics_snapshot_with_scheduler(&sched);
3153 assert_eq!(snap.yield_count, 3);
3154 // The other fields are unchanged by folding in the scheduler.
3155 assert_eq!(snap.kernels_launched, 2);
3156 assert_eq!(snap.device_bytes_allocated, 65536);
3157 }
3158}