Skip to main content

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}