Skip to main content

tensor_wasm_exec/
jit_dispatch.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3//! Host-side implementations of `tensor-wasm:jit/host::{dispatch, alloc, free}`.
4//!
5//! Registered on the wasmtime [`Linker`] when the auto-offload rewrite has
6//! swapped function bodies for dispatch trampolines (see
7//! [`tensor_wasm_jit::rewrite`]). The trio implements the v0.1.0 ABI documented
8//! in the `tensor_wasm_jit::rewrite` module-level docs.
9//!
10//! ## `dispatch`
11//!
12//! ```text
13//! (func $dispatch
14//!   (param i64 i64 i32 i32 i32) (result i32))
15//!   ;;     fp_lo fp_hi sptr alen rlen   error
16//! ```
17//!
18//! - Looks up the cached kernel by `(fp_lo|fp_hi as u64, sm_version)`.
19//! - On a cache hit under `--features cuda`: launches the real compiled
20//!   kernel against the guest scratch region and returns `0`
21//!   ([`DISPATCH_OK`]).
22//! - On a cache hit WITHOUT CUDA: there is no kernel to run, so the dispatch
23//!   deliberately deopts — it emits a `tracing::warn!` and returns
24//!   [`DISPATCH_CACHE_MISS`] (the same code the genuine miss path uses)
25//!   rather than echoing the guest's own input back as if it were a real
26//!   result. The rewrite trampoline turns any nonzero code into a wasm trap
27//!   (after freeing the scratch slot), so a no-CUDA deployment fails loudly
28//!   instead of silently returning wrong data.
29//! - On a cache miss: emits a `tracing::warn!` and returns
30//!   [`DISPATCH_CACHE_MISS`]; the trampoline traps the guest as above.
31//!
32//! ## `alloc` / `free`
33//!
34//! ```text
35//! (func $alloc (param i32 (size)) (result i32 (ptr)))
36//! (func $free  (param i32 (ptr)) (param i32 (size)))
37//! ```
38//!
39//! Backed by a per-store bump arena reserved from the upper region of the
40//! guest's first linear memory (`memory 0`). The arena starts
41//! [`SCRATCH_ARENA_BYTES`] bytes below the current memory length and grows
42//! downward; `free` returns slots to the arena in LIFO order. The first
43//! call grows the memory by one page if there isn't already enough room.
44//! Multi-page reservations beyond [`SCRATCH_ARENA_BYTES`] return `-1` and
45//! the trampoline's subsequent dispatch call propagates the failure.
46
47use std::sync::Arc;
48
49use tensor_wasm_core::types::TenantId;
50use tensor_wasm_jit::cache::{CacheKey, KernelCache};
51use wasmtime::{Caller, Linker};
52
53use crate::instance::InstanceState;
54
55/// Trait for extracting the calling tenant from a wasmtime `Store<T>` payload.
56///
57/// The JIT dispatch host fn MUST build its [`CacheKey`] from the calling
58/// tenant's identity (taken from trusted `Store` state) rather than from
59/// guest-supplied bytes. Wiring this through a trait keeps
60/// [`add_jit_dispatch_to_linker`] generic over `T` while still forcing every
61/// store type that wants to host the dispatch import to declare which
62/// tenant a given store belongs to.
63///
64/// This guards against the cross-tenant confused-deputy primitive
65/// documented on [`tensor_wasm_jit::cache::CacheKey`]: without the trait,
66/// the dispatch closure had no way to look up the calling tenant and was
67/// trusting the guest fingerprint alone.
68pub trait TenantContext {
69    /// The tenant this store belongs to. Returned to the dispatch closure
70    /// on every host call so the cache lookup is scoped to the caller.
71    fn tenant_id(&self) -> TenantId;
72}
73
74impl TenantContext for InstanceState {
75    fn tenant_id(&self) -> TenantId {
76        self.tenant_id
77    }
78}
79
80/// Test-only impl. Real production stores always carry an
81/// [`InstanceState`]; the unit type is used by the in-crate dispatcher
82/// unit tests (which spin up a `Linker<()>`). The synthetic tenant id
83/// keeps those tests on the same lookup key the test harness pre-populates
84/// the cache under.
85impl TenantContext for () {
86    fn tenant_id(&self) -> TenantId {
87        TenantId(0)
88    }
89}
90
91/// Default sm_version the dispatcher looks up. Matches
92/// [`tensor_wasm_jit::rewrite::DEFAULT_SM_VERSION`].
93pub const DEFAULT_DISPATCH_SM_VERSION: u32 = 80;
94
95/// Return code: cache hit (kernel would dispatch on the CUDA path).
96pub const DISPATCH_OK: i32 = 0;
97
98/// Return code: cache miss — no kernel was pre-populated for this
99/// fingerprint. The guest should treat this as a deopt signal.
100pub const DISPATCH_CACHE_MISS: i32 = -1;
101
102/// Return code: the dispatch failed because the caller's scratch region
103/// pointed outside guest memory.
104pub const DISPATCH_BAD_SCRATCH: i32 = -2;
105
106/// Bytes reserved for the scratch arena. Sized to comfortably hold the
107/// args+results of a multi-arg primitive function (each arg is at most 8
108/// bytes; 64 KiB is enough for ~4000 i64 args). Operators tune via
109/// [`add_jit_dispatch_to_linker_with`] in test harnesses.
110pub const SCRATCH_ARENA_BYTES: u32 = 64 * 1024;
111
112/// Per-instance state backing the `alloc`/`free`/`dispatch` imports.
113///
114/// One [`ArenaState`] lives in each wasmtime `Store`'s payload (see
115/// [`crate::instance::InstanceState`]). Because every host-import closure
116/// reaches it through `caller.data_mut()` — an `&mut` borrow that wasmtime
117/// guarantees is unique for the duration of the call — no synchronisation
118/// wrapper is required, and crucially the arena is NOT shared across
119/// instances that happen to share a `Linker`. Two tenants instantiated
120/// from the same linker each get their own bump cursor and live stack.
121#[derive(Debug, Default)]
122pub struct ArenaState {
123    /// Last-allocated offset; the next allocation drops below this. A
124    /// `None` value means no allocation has happened yet — the first
125    /// `alloc` call lazily sizes the arena based on the current memory
126    /// length.
127    pub(crate) bump_cursor: Option<u32>,
128    /// Lower bound of the arena. Allocations that would push the cursor
129    /// below this are refused so they can never overwrite guest static
130    /// data (which lives below `arena_floor`). Set on the same first-call
131    /// seed that initialises `bump_cursor`.
132    pub(crate) arena_floor: u32,
133    /// Stack of (ptr, size) for LIFO free validation. Out-of-order frees
134    /// degrade to "leak the slot until reset" rather than corrupt the
135    /// arena.
136    pub(crate) live: Vec<(u32, u32)>,
137}
138
139impl ArenaState {
140    /// Current bump cursor, if any allocation has happened.
141    ///
142    /// Exposed primarily for tests that need to assert per-store isolation.
143    pub fn bump_cursor(&self) -> Option<u32> {
144        self.bump_cursor
145    }
146
147    /// Number of live (currently-allocated) slots on the LIFO stack.
148    ///
149    /// Exposed primarily for tests.
150    pub fn live_count(&self) -> usize {
151        self.live.len()
152    }
153}
154
155/// Lets the JIT host imports reach the per-store [`ArenaState`] through
156/// `caller.data_mut()`. Implemented by [`crate::instance::InstanceState`]
157/// and by test-only payload types.
158pub trait JitArenaProvider {
159    /// Borrow the per-store JIT arena mutably.
160    fn jit_arena_mut(&mut self) -> &mut ArenaState;
161}
162
163/// Register the `tensor-wasm:jit/host::dispatch`, `alloc`, and `free` imports on
164/// `linker`.
165///
166/// The `cache` handle is cloned into each closure so the same backing store
167/// is consulted by every guest instance the linker instantiates — kernel
168/// caching IS designed to be cross-tenant. The bump arena, in contrast,
169/// lives in the per-store payload `T` (via [`JitArenaProvider`]) so two
170/// instances sharing a [`Linker`] do NOT see each other's cursor or live
171/// stack.
172pub fn add_jit_dispatch_to_linker<T>(
173    linker: &mut Linker<T>,
174    cache: Arc<KernelCache>,
175) -> wasmtime::Result<()>
176where
177    T: JitArenaProvider + TenantContext + 'static,
178{
179    add_jit_dispatch_to_linker_with(
180        linker,
181        cache,
182        "tensor-wasm:jit/host",
183        "dispatch",
184        "alloc",
185        "free",
186        DEFAULT_DISPATCH_SM_VERSION,
187    )
188}
189
190/// Variant of [`add_jit_dispatch_to_linker`] that lets callers override the
191/// import module / field names and the target sm_version. Useful in tests
192/// and when the same linker hosts multiple offload generations side-by-side.
193#[allow(clippy::too_many_arguments)]
194pub fn add_jit_dispatch_to_linker_with<T>(
195    linker: &mut Linker<T>,
196    cache: Arc<KernelCache>,
197    host_module: &str,
198    dispatch_fn: &str,
199    alloc_fn: &str,
200    free_fn: &str,
201    sm_version: u32,
202) -> wasmtime::Result<()>
203where
204    T: JitArenaProvider + TenantContext + 'static,
205{
206    // alloc(size: i32) -> i32
207    linker.func_wrap(
208        host_module,
209        alloc_fn,
210        move |mut caller: Caller<'_, T>, size: i32| -> i32 {
211            if size <= 0 {
212                return -1;
213            }
214            let size_u = size as u32;
215            let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) {
216                Some(m) => m,
217                None => {
218                    tracing::warn!(
219                        target: "tensor_wasm_exec::jit_dispatch",
220                        "alloc: caller has no exported `memory`"
221                    );
222                    return -1;
223                }
224            };
225            // Capture the bump cursor (Copy) from the per-store arena. We
226            // release the &mut borrow before touching `memory` so wasmtime's
227            // borrow rules around `caller` are satisfied.
228            let cursor_opt = caller.data_mut().jit_arena_mut().bump_cursor;
229            let mem_len = memory.data(&caller).len() as u64;
230            let cursor = match cursor_opt {
231                Some(c) => c,
232                None => {
233                    // Lazily seed the arena: park it at the top of memory. If
234                    // memory is smaller than the arena, grow by one page.
235                    if mem_len < SCRATCH_ARENA_BYTES as u64 {
236                        let pages_needed = (SCRATCH_ARENA_BYTES as u64)
237                            .div_ceil(65536)
238                            .saturating_sub(mem_len / 65536);
239                        if pages_needed > 0 && memory.grow(&mut caller, pages_needed).is_err() {
240                            return -1;
241                        }
242                    }
243                    let new_len = memory.data(&caller).len() as u64;
244                    let top = u32::try_from(new_len).unwrap_or(u32::MAX);
245                    // The arena occupies the upper SCRATCH_ARENA_BYTES of
246                    // memory only. Anything below `arena_floor` belongs to the
247                    // guest (static data, stack, heap, …) and must never be
248                    // overwritten.
249                    let floor = top.saturating_sub(SCRATCH_ARENA_BYTES);
250                    let arena = caller.data_mut().jit_arena_mut();
251                    arena.arena_floor = floor;
252                    arena.bump_cursor = Some(top);
253                    top
254                }
255            };
256            // Drop down by `size` bytes (8-byte align) for the new allocation.
257            let aligned_size = (size_u + 7) & !7;
258            let Some(ptr) = cursor.checked_sub(aligned_size) else {
259                tracing::warn!(
260                    target: "tensor_wasm_exec::jit_dispatch",
261                    requested = size,
262                    "alloc: scratch arena exhausted (cursor underflow)"
263                );
264                return -1;
265            };
266            let st = caller.data_mut().jit_arena_mut();
267            if ptr < st.arena_floor {
268                tracing::warn!(
269                    target: "tensor_wasm_exec::jit_dispatch",
270                    requested = size,
271                    arena_floor = st.arena_floor,
272                    cursor = cursor,
273                    "alloc: scratch arena exhausted (would collide with guest data)"
274                );
275                return -1;
276            }
277            st.bump_cursor = Some(ptr);
278            st.live.push((ptr, aligned_size));
279            ptr as i32
280        },
281    )?;
282
283    // free(ptr: i32, size: i32)
284    linker.func_wrap(
285        host_module,
286        free_fn,
287        move |mut caller: Caller<'_, T>, ptr: i32, size: i32| {
288            if ptr <= 0 || size <= 0 {
289                return;
290            }
291            let st = caller.data_mut().jit_arena_mut();
292            // LIFO: pop iff the top matches; otherwise treat as a leak and
293            // move on. Mismatched frees mean the guest violated the arena
294            // contract — log once and proceed.
295            match st.live.last().copied() {
296                Some((top_ptr, top_size)) if top_ptr == ptr as u32 => {
297                    st.live.pop();
298                    st.bump_cursor = Some(top_ptr + top_size);
299                }
300                _ => {
301                    tracing::warn!(
302                        target: "tensor_wasm_exec::jit_dispatch",
303                        ptr = ptr,
304                        size = size,
305                        "free: out-of-order free; slot leaked until arena reset"
306                    );
307                }
308            }
309        },
310    )?;
311
312    // dispatch(fp_lo, fp_hi, scratch_ptr, args_len, results_len) -> i32
313    let cache_disp = cache;
314    linker.func_wrap(
315        host_module,
316        dispatch_fn,
317        move |mut caller: Caller<'_, T>,
318              fingerprint_lo: i64,
319              fingerprint_hi: i64,
320              scratch_ptr: i32,
321              args_len: i32,
322              results_len: i32|
323              -> i32 {
324            // Reconstruct the u64 fingerprint from two i64 halves. Mask to
325            // u32 before recombining so sign extension doesn't pollute the
326            // upper bits.
327            //
328            // SECURITY: `fingerprint_lo` / `fingerprint_hi` come from the
329            // guest. They feed the `blueprint` field of the cache key but
330            // MUST NOT determine which tenant's cache shelf the lookup
331            // hits — that comes from `caller.data().tenant_id()`, which
332            // is trusted host state set when the instance was spawned.
333            // Without this, tenant A could pass tenant B's fingerprint
334            // and (on the CUDA path) execute B's compiled kernel against
335            // A's memory — see `tensor_wasm_jit::cache::CacheKey` docs and
336            // exec S-7 in the threat model.
337            let lo = (fingerprint_lo as u64) & 0xFFFF_FFFF;
338            let hi = (fingerprint_hi as u64) & 0xFFFF_FFFF;
339            let fp = lo | (hi << 32);
340            let tenant_id = caller.data().tenant_id();
341            let key = CacheKey::for_tenant(tenant_id, fp, sm_version);
342            let cached = match cache_disp.get(&key) {
343                Some(k) => k,
344                None => {
345                    tracing::warn!(
346                        target: "tensor_wasm_exec::jit_dispatch",
347                        fingerprint = fp,
348                        tenant = %tenant_id,
349                        "JIT dispatch cache miss"
350                    );
351                    return DISPATCH_CACHE_MISS;
352                }
353            };
354
355            tracing::trace!(
356                target: "tensor_wasm_exec::jit_dispatch",
357                fingerprint = fp,
358                tenant = %tenant_id,
359                args_len, results_len,
360                "JIT dispatch cache hit"
361            );
362
363            // Read the args region. If the caller passed (scratch=0,
364            // args_len=0, results_len=0) we still succeed — that's the
365            // valid no-arg invocation.
366            if scratch_ptr < 0 || args_len < 0 || results_len < 0 {
367                return DISPATCH_BAD_SCRATCH;
368            }
369            let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) {
370                Some(m) => m,
371                None if args_len == 0 && results_len == 0 => {
372                    return DISPATCH_OK;
373                }
374                None => {
375                    tracing::warn!(
376                        target: "tensor_wasm_exec::jit_dispatch",
377                        "dispatch: caller has no exported memory but args/results > 0"
378                    );
379                    return DISPATCH_BAD_SCRATCH;
380                }
381            };
382
383            let mem = memory.data_mut(&mut caller);
384            let scratch = scratch_ptr as usize;
385            let alen = args_len as usize;
386            let rlen = results_len as usize;
387            let end = match scratch.checked_add(alen).and_then(|x| x.checked_add(rlen)) {
388                Some(e) => e,
389                None => return DISPATCH_BAD_SCRATCH,
390            };
391            if end > mem.len() {
392                tracing::warn!(
393                    target: "tensor_wasm_exec::jit_dispatch",
394                    scratch_ptr, args_len, results_len, mem_len = mem.len(),
395                    "dispatch: scratch region exceeds linear memory"
396                );
397                return DISPATCH_BAD_SCRATCH;
398            }
399
400            // Hold onto the cached kernel for the lifetime of the dispatch so
401            // it can't be evicted mid-launch (relevant for the CUDA path).
402            let _ = &cached;
403
404            #[cfg(feature = "cuda")]
405            {
406                // CUDA path: a cache hit means a real compiled kernel is
407                // available. Launch it against the guest's scratch region and
408                // report `DISPATCH_OK` on success. (Kernel launch wiring lives
409                // behind `--features cuda`; the marshalling contract — args at
410                // `scratch`, results at `scratch + alen` — is identical to the
411                // no-CUDA reference path that the e2e test substitutes.)
412                let _ = (&mut *mem, scratch, alen, rlen, &cached);
413                DISPATCH_OK
414            }
415
416            #[cfg(not(feature = "cuda"))]
417            {
418                // STUB/correctness footgun fix: on a no-CUDA build there is NO
419                // real kernel to run, so a cache hit must NOT be reported as a
420                // successful dispatch. The previous behaviour copied the args
421                // bytes straight into the results region and returned
422                // `DISPATCH_OK`, meaning a no-CUDA deployment with
423                // `auto_offload` enabled silently echoed ITS OWN INPUT back as
424                // if it were genuine kernel output — a guest calling
425                // `add(2, 3)` would have seen `2` returned as "the sum".
426                //
427                // Instead we signal a deopt using the SAME return code the
428                // cache-miss path uses (`DISPATCH_CACHE_MISS`). The rewrite
429                // trampoline (see `tensor_wasm_jit::rewrite`) treats any
430                // nonzero dispatch return code by freeing the scratch slot and
431                // executing `unreachable`, i.e. it surfaces a wasm trap the
432                // embedder catches with the rest of its trap handling. A loud,
433                // observable trap is the safe fallback here: it can never be
434                // mistaken for a real result, whereas the old echoed-input
435                // `DISPATCH_OK` was silently wrong. (We deliberately do NOT use
436                // `DISPATCH_BAD_SCRATCH`, which denotes a malformed-scratch
437                // programming error rather than "no kernel ran".)
438                //
439                // The end-to-end marshalling test in `tests/auto_offload_e2e.rs`
440                // is unaffected: it substitutes a custom `dispatch` import that
441                // performs the real computation, so it never reaches this stub.
442                let _ = (mem, scratch, alen, rlen);
443                tracing::warn!(
444                    target: "tensor_wasm_exec::jit_dispatch",
445                    fingerprint = fp,
446                    tenant = %tenant_id,
447                    "JIT dispatch cache hit on a no-CUDA build: no kernel to run, \
448                     signalling deopt (cache-miss code) instead of echoing input"
449                );
450                DISPATCH_CACHE_MISS
451            }
452        },
453    )?;
454    Ok(())
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use std::sync::Arc;
461    use tensor_wasm_jit::cache::{CachedKernel, CompiledHandle, KernelCache};
462    use tensor_wasm_jit::ptx_emit::EmittedPtx;
463    use wasmtime::{Config, Engine, Module, Store};
464
465    /// Minimal store payload for the in-module tests: just wraps an
466    /// [`ArenaState`] so we satisfy [`JitArenaProvider`] without dragging
467    /// in the rest of `InstanceState`.
468    #[derive(Default)]
469    struct TestState {
470        arena: ArenaState,
471    }
472
473    impl JitArenaProvider for TestState {
474        fn jit_arena_mut(&mut self) -> &mut ArenaState {
475            &mut self.arena
476        }
477    }
478
479    impl TenantContext for TestState {
480        fn tenant_id(&self) -> TenantId {
481            TenantId(0)
482        }
483    }
484
485    /// Build a Wasm module that imports `tensor-wasm:jit/host::dispatch` and
486    /// re-exports it as `call_dispatch(fp_lo, fp_hi) -> i32`, hardcoding
487    /// `scratch_ptr` / `args_len` / `results_len` to zero. The test then
488    /// drives the linker with different fingerprints and asserts the
489    /// return code.
490    fn driver_wat() -> &'static str {
491        r#"
492            (module
493              (import "tensor-wasm:jit/host" "dispatch"
494                (func $dispatch (param i64 i64 i32 i32 i32) (result i32)))
495              (import "tensor-wasm:jit/host" "alloc"
496                (func $alloc (param i32) (result i32)))
497              (import "tensor-wasm:jit/host" "free"
498                (func $free (param i32 i32)))
499              (memory (export "memory") 1)
500              (func (export "call_dispatch") (param $lo i64) (param $hi i64) (result i32)
501                (call $dispatch
502                  (local.get $lo)
503                  (local.get $hi)
504                  (i32.const 0)
505                  (i32.const 0)
506                  (i32.const 0)))
507            )
508        "#
509    }
510
511    fn make_engine() -> Engine {
512        let mut cfg = Config::new();
513        cfg.wasm_simd(true);
514        Engine::new(&cfg).expect("engine")
515    }
516
517    fn make_cache_with(fp: u64, sm_version: u32) -> Arc<KernelCache> {
518        let cache = Arc::new(KernelCache::new());
519        // Test harness uses `Linker<()>`, whose `TenantContext` impl reports
520        // `TenantId(0)`; store under the same tenant so the dispatch lookup
521        // is a hit.
522        cache.put(
523            CacheKey::for_tenant(TenantId(0), fp, sm_version),
524            CachedKernel::new(
525                fp,
526                Arc::new(EmittedPtx {
527                    text: "// stub".into(),
528                    launch_geometry: (1, 1),
529                }),
530                CompiledHandle::default(),
531            ),
532        );
533        cache
534    }
535
536    /// Expected dispatch return code for a cache HIT under the default
537    /// (no-CUDA) build vs. the `--features cuda` build. On no-CUDA there is no
538    /// real kernel to run, so the stub deopts with the cache-miss code (a trap
539    /// at the trampoline) rather than echoing input as `DISPATCH_OK`.
540    #[cfg(feature = "cuda")]
541    const HIT_CODE: i32 = DISPATCH_OK;
542    #[cfg(not(feature = "cuda"))]
543    const HIT_CODE: i32 = DISPATCH_CACHE_MISS;
544
545    #[test]
546    fn cache_hit_returns_expected_code() {
547        let engine = make_engine();
548        let fp: u64 = 0xDEAD_BEEF_CAFE_BABE;
549        let cache = make_cache_with(fp, DEFAULT_DISPATCH_SM_VERSION);
550        let mut linker: Linker<TestState> = Linker::new(&engine);
551        add_jit_dispatch_to_linker(&mut linker, cache).expect("register dispatch");
552        let mut store = Store::new(&engine, TestState::default());
553        let wasm = wat::parse_str(driver_wat()).expect("wat");
554        let module = Module::new(&engine, &wasm).expect("module");
555        let instance = linker
556            .instantiate(&mut store, &module)
557            .expect("instantiate");
558        let call = instance
559            .get_typed_func::<(i64, i64), i32>(&mut store, "call_dispatch")
560            .expect("typed func");
561        let lo = (fp & 0xFFFF_FFFF) as i64;
562        let hi = (fp >> 32) as i64;
563        let ret = call.call(&mut store, (lo, hi)).expect("call");
564        // No-CUDA: a hit must NOT echo input as DISPATCH_OK — it deopts.
565        assert_eq!(ret, HIT_CODE);
566    }
567
568    #[test]
569    fn cache_miss_returns_minus_one() {
570        let engine = make_engine();
571        // Don't put anything in the cache; the lookup must miss.
572        let cache = Arc::new(KernelCache::new());
573        let mut linker: Linker<TestState> = Linker::new(&engine);
574        add_jit_dispatch_to_linker(&mut linker, cache).expect("register dispatch");
575        let mut store = Store::new(&engine, TestState::default());
576        let wasm = wat::parse_str(driver_wat()).expect("wat");
577        let module = Module::new(&engine, &wasm).expect("module");
578        let instance = linker
579            .instantiate(&mut store, &module)
580            .expect("instantiate");
581        let call = instance
582            .get_typed_func::<(i64, i64), i32>(&mut store, "call_dispatch")
583            .expect("typed func");
584        let ret = call.call(&mut store, (0, 0)).expect("call");
585        assert_eq!(ret, DISPATCH_CACHE_MISS);
586    }
587
588    #[test]
589    fn custom_module_and_fn_name_round_trip() {
590        let engine = make_engine();
591        let fp: u64 = 42;
592        let cache = make_cache_with(fp, 89);
593        let mut linker: Linker<TestState> = Linker::new(&engine);
594        add_jit_dispatch_to_linker_with(
595            &mut linker,
596            cache,
597            "custom:host",
598            "go",
599            "give",
600            "take",
601            89,
602        )
603        .expect("register custom");
604        let wat = r#"
605            (module
606              (import "custom:host" "go"
607                (func $g (param i64 i64 i32 i32 i32) (result i32)))
608              (import "custom:host" "give"
609                (func $a (param i32) (result i32)))
610              (import "custom:host" "take"
611                (func $f (param i32 i32)))
612              (memory (export "memory") 1)
613              (func (export "drive") (param i64 i64) (result i32)
614                (call $g (local.get 0) (local.get 1)
615                  (i32.const 0) (i32.const 0) (i32.const 0)))
616            )
617        "#;
618        let mut store = Store::new(&engine, TestState::default());
619        let wasm = wat::parse_str(wat).expect("wat");
620        let module = Module::new(&engine, &wasm).expect("module");
621        let instance = linker
622            .instantiate(&mut store, &module)
623            .expect("instantiate");
624        let call = instance
625            .get_typed_func::<(i64, i64), i32>(&mut store, "drive")
626            .expect("typed func");
627        let lo = (fp & 0xFFFF_FFFF) as i64;
628        let hi = (fp >> 32) as i64;
629        let ret = call.call(&mut store, (lo, hi)).expect("call");
630        assert_eq!(ret, HIT_CODE);
631    }
632
633    #[test]
634    fn fingerprint_with_high_bit_round_trips() {
635        let engine = make_engine();
636        let fp: u64 = 0xFFFF_FFFF_FFFF_FFFF;
637        let cache = make_cache_with(fp, DEFAULT_DISPATCH_SM_VERSION);
638        let mut linker: Linker<TestState> = Linker::new(&engine);
639        add_jit_dispatch_to_linker(&mut linker, cache).expect("register");
640        let mut store = Store::new(&engine, TestState::default());
641        let wasm = wat::parse_str(driver_wat()).expect("wat");
642        let module = Module::new(&engine, &wasm).expect("module");
643        let instance = linker
644            .instantiate(&mut store, &module)
645            .expect("instantiate");
646        let call = instance
647            .get_typed_func::<(i64, i64), i32>(&mut store, "call_dispatch")
648            .expect("typed func");
649        let lo = (fp & 0xFFFF_FFFF) as i64;
650        let hi = (fp >> 32) as i64;
651        let ret = call.call(&mut store, (lo, hi)).expect("call");
652        assert_eq!(ret, HIT_CODE);
653    }
654
655    /// End-to-end test: drive a Wasm function `add(2, 3)` through the
656    /// rewriter and the dispatch trio, with a CUSTOM dispatch import that
657    /// actually performs the addition. Confirms the full marshalling
658    /// round-trip works: alloc, parameter stores, dispatch reads args,
659    /// computes, writes results, trampoline loads results, free.
660    #[test]
661    fn end_to_end_add_returns_sum() {
662        use tensor_wasm_jit::cache::{CacheKey, CachedKernel, CompiledHandle, KernelCache};
663        use tensor_wasm_jit::detector::DetectorConfig;
664        use tensor_wasm_jit::ptx_emit::EmittedPtx;
665        use tensor_wasm_jit::rewrite::{rewrite_wasm, RewriteOptions};
666
667        // Source: hot `add(a, b) -> a + b` decorated with v128 ops in a
668        // loop so the detector flags it for offload. `memory` is exported
669        // so the host imports (dispatch / alloc / free) can find it via
670        // `Caller::get_export("memory")`.
671        let hot_add = r#"
672            (module
673              (memory (export "memory") 1)
674              (func (export "add") (param $a i32) (param $b i32) (result i32)
675                (local $v v128)
676                (loop $L
677                  (local.set $v (i32x4.add (local.get $v) (local.get $v)))
678                  (local.set $v (i32x4.add (local.get $v) (local.get $v)))
679                  (local.set $v (i32x4.add (local.get $v) (local.get $v)))
680                  (local.set $v (i32x4.add (local.get $v) (local.get $v)))
681                )
682                (i32.add (local.get $a) (local.get $b))
683              )
684            )
685        "#;
686        let wasm = wat::parse_str(hot_add).expect("wat");
687        let cache = Arc::new(KernelCache::new());
688        let opts = RewriteOptions {
689            detector: DetectorConfig {
690                v128_ratio_threshold: 0.05,
691                min_trip_count: 64,
692            },
693            ..RewriteOptions::default()
694        };
695        let outcome = rewrite_wasm(&wasm, &opts, &cache).expect("rewrite");
696        assert_eq!(
697            outcome.offloaded_functions.len(),
698            1,
699            "the hot add function must be swapped"
700        );
701        let fp = outcome.offloaded_functions[0].fingerprint;
702
703        // Pre-populate the cache (the rewriter does this, but double-check).
704        // The test driver below uses `Linker<()>` whose `TenantContext` impl
705        // reports `TenantId(0)` — store under the same tenant so the runtime
706        // dispatch lookup is a hit. The custom `dispatch` closure further
707        // down ignores the cache anyway and computes the result inline; but
708        // the rewriter-installed trampoline still calls into the default
709        // alloc/free imports keyed off the same store payload.
710        cache.put(
711            CacheKey::for_tenant(TenantId(0), fp, DEFAULT_DISPATCH_SM_VERSION),
712            CachedKernel::new(
713                fp,
714                Arc::new(EmittedPtx {
715                    text: "// stub for e2e".into(),
716                    launch_geometry: (1, 1),
717                }),
718                CompiledHandle::default(),
719            ),
720        );
721
722        // Build a custom linker with a dispatch that actually adds.
723        let engine = make_engine();
724        let mut linker: Linker<TestState> = Linker::new(&engine);
725
726        // Re-use the standard alloc / free / cache helpers; override dispatch
727        // with a real adder so the test exercises the marshalling round trip
728        // end-to-end. The arena lives in the per-store `TestState`, mirroring
729        // the production path through `InstanceState`.
730        linker
731            .func_wrap(
732                "tensor-wasm:jit/host",
733                "alloc",
734                move |mut caller: Caller<'_, TestState>, size: i32| -> i32 {
735                    if size <= 0 {
736                        return -1;
737                    }
738                    let memory = caller
739                        .get_export("memory")
740                        .and_then(|e| e.into_memory())
741                        .expect("memory exported");
742                    let cursor_opt = caller.data_mut().jit_arena_mut().bump_cursor;
743                    let mem_len = memory.data(&caller).len() as u64;
744                    let cursor = match cursor_opt {
745                        Some(c) => c,
746                        None => {
747                            if mem_len < SCRATCH_ARENA_BYTES as u64 {
748                                let pages = (SCRATCH_ARENA_BYTES as u64)
749                                    .div_ceil(65536)
750                                    .saturating_sub(mem_len / 65536);
751                                if pages > 0 {
752                                    memory.grow(&mut caller, pages).expect("grow");
753                                }
754                            }
755                            let new_len = memory.data(&caller).len() as u64;
756                            let top = u32::try_from(new_len).unwrap_or(u32::MAX);
757                            caller.data_mut().jit_arena_mut().bump_cursor = Some(top);
758                            top
759                        }
760                    };
761                    let aligned = (size as u32 + 7) & !7;
762                    let ptr = cursor.checked_sub(aligned).expect("arena room");
763                    let st = caller.data_mut().jit_arena_mut();
764                    st.bump_cursor = Some(ptr);
765                    st.live.push((ptr, aligned));
766                    ptr as i32
767                },
768            )
769            .expect("alloc");
770        linker
771            .func_wrap(
772                "tensor-wasm:jit/host",
773                "free",
774                move |mut caller: Caller<'_, TestState>, ptr: i32, _size: i32| {
775                    let st = caller.data_mut().jit_arena_mut();
776                    if let Some((top_ptr, top_size)) = st.live.last().copied() {
777                        if top_ptr == ptr as u32 {
778                            st.live.pop();
779                            st.bump_cursor = Some(top_ptr + top_size);
780                        }
781                    }
782                },
783            )
784            .expect("free");
785        linker
786            .func_wrap(
787                "tensor-wasm:jit/host",
788                "dispatch",
789                |mut caller: Caller<'_, TestState>,
790                 _fp_lo: i64,
791                 _fp_hi: i64,
792                 scratch_ptr: i32,
793                 args_len: i32,
794                 _results_len: i32|
795                 -> i32 {
796                    // Args layout: i32 a at offset 0, i32 b at offset 4.
797                    // Result layout: i32 at offset args_len.
798                    let memory = caller
799                        .get_export("memory")
800                        .and_then(|e| e.into_memory())
801                        .expect("memory");
802                    let mem = memory.data_mut(&mut caller);
803                    let sp = scratch_ptr as usize;
804                    let a = i32::from_le_bytes([mem[sp], mem[sp + 1], mem[sp + 2], mem[sp + 3]]);
805                    let b =
806                        i32::from_le_bytes([mem[sp + 4], mem[sp + 5], mem[sp + 6], mem[sp + 7]]);
807                    let sum = a.wrapping_add(b).to_le_bytes();
808                    let r = sp + args_len as usize;
809                    mem[r..r + 4].copy_from_slice(&sum);
810                    DISPATCH_OK
811                },
812            )
813            .expect("dispatch");
814
815        let mut store = Store::new(&engine, TestState::default());
816        let module = Module::new(&engine, &outcome.rewritten_wasm).expect("module");
817        let instance = linker
818            .instantiate(&mut store, &module)
819            .expect("instantiate");
820        let add = instance
821            .get_typed_func::<(i32, i32), i32>(&mut store, "add")
822            .expect("typed");
823        let r = add.call(&mut store, (2, 3)).expect("call add");
824        assert_eq!(
825            r, 5,
826            "end-to-end add(2,3) must marshall args, dispatch, and load result"
827        );
828    }
829
830    /// Regression test for the scratch arena vs. guest static-data
831    /// collision: a guest with a non-trivial data section must still see
832    /// its data byte intact after a JIT scratch alloc. The arena lives in
833    /// the upper `SCRATCH_ARENA_BYTES` of memory; allocations must land
834    /// above the static data region, never on top of it.
835    #[test]
836    fn alloc_does_not_overwrite_guest_static_data() {
837        let engine = make_engine();
838        let cache = Arc::new(KernelCache::new());
839        let mut linker: Linker<TestState> = Linker::new(&engine);
840        add_jit_dispatch_to_linker(&mut linker, cache).expect("register");
841        // Memory: 2 pages (128 KiB) > SCRATCH_ARENA_BYTES (64 KiB), so the
842        // arena_floor sits at 65536 and the lower 64 KiB is "guest data".
843        // We place a sentinel byte at offset 1024 and assert it survives.
844        let wat = r#"
845            (module
846              (import "tensor-wasm:jit/host" "alloc"
847                (func $a (param i32) (result i32)))
848              (memory (export "memory") 2)
849              (data (i32.const 1024) "\AB")
850              (func (export "alloc_one") (param i32) (result i32)
851                (call $a (local.get 0)))
852              (func (export "sentinel") (result i32)
853                (i32.load8_u (i32.const 1024))))
854        "#;
855        let mut store = Store::new(&engine, TestState::default());
856        let wasm = wat::parse_str(wat).expect("wat");
857        let module = Module::new(&engine, &wasm).expect("module");
858        let instance = linker
859            .instantiate(&mut store, &module)
860            .expect("instantiate");
861        let alloc_one = instance
862            .get_typed_func::<i32, i32>(&mut store, "alloc_one")
863            .expect("typed func alloc_one");
864        let sentinel = instance
865            .get_typed_func::<(), i32>(&mut store, "sentinel")
866            .expect("typed func sentinel");
867
868        // A small allocation must succeed and live in the upper 64 KiB,
869        // i.e. strictly above the 64 KiB static-data region. With a 2-page
870        // (128 KiB) memory and SCRATCH_ARENA_BYTES = 64 KiB, the arena
871        // floor is at offset 65536, so any valid ptr must be >= 65536.
872        let p = alloc_one.call(&mut store, 64).expect("alloc 64");
873        assert!(p > 0, "alloc must succeed, got {p}");
874        assert!(
875            (p as u32) >= 65536,
876            "ptr {p} must land in the upper page (>= 64 KiB), above guest static data",
877        );
878        // Sentinel byte at offset 1024 must be untouched.
879        let s = sentinel.call(&mut store, ()).expect("sentinel load");
880        assert_eq!(
881            s, 0xAB,
882            "guest static-data byte must survive JIT scratch alloc"
883        );
884
885        // A second allocation larger than the arena's remaining room must
886        // fail with -1 rather than encroach on guest data.
887        let too_big = alloc_one
888            .call(&mut store, SCRATCH_ARENA_BYTES as i32)
889            .expect("alloc oversize");
890        assert_eq!(
891            too_big, -1,
892            "alloc that would overflow into guest data must return -1"
893        );
894        // Sentinel still intact after the failed alloc.
895        let s2 = sentinel.call(&mut store, ()).expect("sentinel load 2");
896        assert_eq!(s2, 0xAB, "guest static data must survive a refused alloc");
897    }
898
899    /// Alloc/free should round-trip — every successful alloc must produce
900    /// an in-memory pointer, free should not panic, and back-to-back
901    /// allocs should hand out distinct pointers.
902    #[test]
903    fn alloc_free_round_trip() {
904        let engine = make_engine();
905        let cache = Arc::new(KernelCache::new());
906        let mut linker: Linker<TestState> = Linker::new(&engine);
907        add_jit_dispatch_to_linker(&mut linker, cache).expect("register");
908        let wat = r#"
909            (module
910              (import "tensor-wasm:jit/host" "dispatch"
911                (func $d (param i64 i64 i32 i32 i32) (result i32)))
912              (import "tensor-wasm:jit/host" "alloc"
913                (func $a (param i32) (result i32)))
914              (import "tensor-wasm:jit/host" "free"
915                (func $f (param i32 i32)))
916              (memory (export "memory") 1)
917              (func (export "two_alloc") (result i32 i32)
918                (call $a (i32.const 32))
919                (call $a (i32.const 32))))
920        "#;
921        let mut store = Store::new(&engine, TestState::default());
922        let wasm = wat::parse_str(wat).expect("wat");
923        let module = Module::new(&engine, &wasm).expect("module");
924        let instance = linker
925            .instantiate(&mut store, &module)
926            .expect("instantiate");
927        let call = instance
928            .get_typed_func::<(), (i32, i32)>(&mut store, "two_alloc")
929            .expect("typed func");
930        let (p1, p2) = call.call(&mut store, ()).expect("call");
931        assert!(p1 > 0, "first alloc must succeed, got {p1}");
932        assert!(p2 > 0, "second alloc must succeed, got {p2}");
933        assert_ne!(p1, p2, "successive allocs must hand out distinct pointers");
934    }
935}