tensor-wasm-wasi-gpu 0.3.8

`wasi-cuda` host bridge — explicit GPU kernel launch API for Wasm modules.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Craton Software Company

//! End-to-end coverage of the v0.2 typed-argv kernel-args marshalling
//! path through the `wasi:cuda` `launch` host function.
//!
//! Each test builds a tiny WAT module that writes a tagged-argv buffer
//! into linear memory and calls `wasi_cuda_launch` with that buffer.
//! On no-CUDA hosts the launch returns `NotAvailable` — the assertion
//! is that the host parsed the argv (visible via
//! `WasiCudaContext::last_lowered_args`) before reporting the no-CUDA
//! signal, exercising the parser end-to-end.
//!
//! The CUDA-only assertions (kernel actually runs, output buffer
//! mutated) live behind `#[ignore = "requires CUDA hardware"]` so the
//! body still compiles under `cargo test --include-ignored` on a
//! no-CUDA developer laptop.
//!
//! ## v0.3.3 end-to-end pipeline proof (audit Problem #14)
//!
//! The original five tests in this file confirmed that the argv
//! marshalling path is reachable and that the parsed `LoweredArg`
//! sequence matches the input — but they all register a *stub* kernel
//! (`module: None`), so under `--features cuda` the launch fails at
//! the `InvalidKernel` gate before `cuLaunchKernel` ever runs. The
//! audit closed v0.3.2 with that gap explicit: nothing in the test
//! suite proved Wasm → wasi-cuda → `cuLaunchKernel` → result-readback
//! actually produces correct output on real hardware.
//!
//! The two `vector_add_*` / `dispatch_pipeline_*` tests at the bottom
//! of the file close that gap:
//!
//! - [`vector_add_end_to_end_real_ptx_real_kernel`] is the SM_80 happy
//!   path: build a Wasm guest holding three f32[64] arrays in linear
//!   memory, register the canonical `kernels/vector_add.ptx` via
//!   `cust::module::Module::from_ptx`, launch with `grid_x = 1,
//!   block_x = 64`, then read the `c` region back out of linear memory
//!   and assert `c[i] == a[i] + b[i]` for all `i`. `#[ignore]`d
//!   because the SM_80 PTX may be rejected by `ptxas` on SM_75 dev
//!   boxes; the v0.4 self-hosted runner (S22) picks it up.
//! - [`dispatch_pipeline_compiles_against_real_module_bytes`] is the
//!   v0.3.3 commitment: it runs *unignored* on every CUDA-capable host
//!   and asserts that the registry + load_ptx + launch surface accepts
//!   the real PTX bytes and routes them through the dispatch path.
//!   The launch may fail downstream (e.g. on SM_75 the `Module::from_ptx`
//!   JIT compile rejects the SM_80 target), but the failure shape is
//!   bounded: it is one of `{Ok, MalformedPtx, LaunchFailed,
//!   InvalidKernel}` — never `InvalidArgs` or `InvalidPointer`, which
//!   would indicate the marshalling layer regressed.

use tensor_wasm_core::types::InstanceId;
use tensor_wasm_wasi_gpu::abi::{AbiError, FN_LAUNCH, MODULE};
use tensor_wasm_wasi_gpu::host::{add_to_linker, HasWasiCuda, WasiCudaContext};
use tensor_wasm_wasi_gpu::kernel_args::{
    build_kernel_param_storage, encode_argv, LoweredArg, LoweredArgSnapshot,
};
use tensor_wasm_wasi_gpu::registry::KernelEntry;

struct TestStore {
    cuda: WasiCudaContext,
}

impl HasWasiCuda for TestStore {
    fn wasi_cuda(&self) -> &WasiCudaContext {
        &self.cuda
    }
}

fn make_engine_and_linker() -> (wasmtime::Engine, wasmtime::Linker<TestStore>) {
    let config = wasmtime::Config::new();
    let engine = wasmtime::Engine::new(&config).expect("engine");
    let mut linker: wasmtime::Linker<TestStore> = wasmtime::Linker::new(&engine);
    add_to_linker(&mut linker).expect("add_to_linker");
    (engine, linker)
}

/// Like [`make_engine_and_linker`], but backs guest linear memory with CUDA
/// Unified Memory via [`TensorWasmMemoryCreator`] (finding #8).
///
/// The end-to-end launch test hands the kernel pointer args that resolve to
/// host addresses inside the guest's linear memory. For `cuLaunchKernel` to
/// dereference those pointers on the device, the linear memory must be
/// device-addressable — i.e. allocated with `cuMemAllocManaged`, not plain host
/// heap. Installing `TensorWasmMemoryCreator` (whose `new_memory` allocates a
/// `UnifiedBuffer`, which under `tensor-wasm-mem/unified-memory` is managed
/// memory) makes the guest's exported `memory` managed, so the same address the
/// host reads back is the one the kernel wrote through. Without this the launch
/// fails with `CUDA_ERROR_INVALID_VALUE` (BUG-8 in
/// `docs/GPU-VALIDATION-2026-05-30.md`).
#[cfg(feature = "cuda")]
fn make_managed_engine_and_linker() -> (wasmtime::Engine, wasmtime::Linker<TestStore>) {
    use tensor_wasm_mem::wasm_memory::TensorWasmMemoryCreator;
    let mut config = wasmtime::Config::new();
    // `TensorWasmMemoryCreator` returns fixed-size `cuMemAllocManaged` regions
    // that cannot satisfy wasmtime's default 4 GiB static reservation or its
    // guard pages (the CUDA driver migrates managed pages, incompatible with
    // host mprotect guards). Zero all three knobs so wasmtime asks the host
    // allocator for the EXACT byte length. This is the SAME recipe
    // tensor-wasm-exec's `MemoryBackend::UnifiedBuffer` uses (engine.rs).
    config.with_host_memory(std::sync::Arc::new(TensorWasmMemoryCreator::default()));
    config.guard_before_linear_memory(false);
    config.memory_reservation(0);
    config.memory_guard_size(0);
    config.async_support(true);
    // Wasmtime async host calls (`wasi_cuda_launch` -> `cuLaunchKernel`) run on
    // the guest's fiber stack. The default 1 MiB fiber is too small once the
    // launch actually SUCCEEDS and descends into the CUDA driver's
    // `cuLaunchKernel` chain (the non-managed path never reached this -- it
    // failed early with INVALID_VALUE). Give the fiber 16 MiB so a real driver
    // dispatch cannot overflow it.
    config.async_stack_size(16 << 20);
    let engine = wasmtime::Engine::new(&config).expect("engine");
    let mut linker: wasmtime::Linker<TestStore> = wasmtime::Linker::new(&engine);
    add_to_linker(&mut linker).expect("add_to_linker");
    (engine, linker)
}

/// Build a tiny WAT module that copies `argv_bytes` into linear memory
/// at offset `argv_offset` (via a `data` segment) and exports a
/// `launch_with_args` function that hands that buffer to
/// `wasi_cuda_launch` with a 1x1x1 grid + block.
///
/// The kernel id `kernel_id` is passed through unchanged so the test
/// can register a real `KernelEntry` and reach the marshalling path
/// (rather than failing at the `InvalidKernel` gate).
fn build_launch_wat(argv_bytes: &[u8], argv_offset: usize, kernel_id: u64) -> String {
    let data_literal = wat_data_literal(argv_bytes);
    format!(
        r#"
        (module
          (import "{m}" "{fn_name}"
              (func $launch (param i64 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32)))
          (memory (export "memory") 4)
          (data (i32.const {data_offset}) "{data_literal}")
          (func (export "launch_with_args") (result i32)
            (call $launch
              (i64.const {kernel_id})
              (i32.const 1) (i32.const 1) (i32.const 1)
              (i32.const 1) (i32.const 1) (i32.const 1)
              (i32.const 0)
              (i32.const {argv_offset})
              (i32.const {argv_len}))))
        "#,
        m = MODULE,
        fn_name = FN_LAUNCH,
        data_offset = argv_offset,
        argv_len = argv_bytes.len(),
        kernel_id = kernel_id,
        data_literal = data_literal,
    )
}

/// Register a stub kernel in the context's registry and return its id.
/// The non-CUDA path uses only `owner`, `entry`, and `ptx_bytes_len` so
/// `module: None` is fine.
fn register_stub_kernel(ctx: &WasiCudaContext, owner: InstanceId, name: &str) -> u64 {
    let kid = ctx
        .registry
        .register(KernelEntry {
            owner,
            entry: name.into(),
            ptx_bytes_len: 256,
            #[cfg(feature = "cuda")]
            module: None,
        })
        .expect("register");
    kid.0
}

/// Encode a slice of bytes as a WAT `data` segment literal (each byte
/// formatted as `\xx`). Shared between the argv-encoding path in
/// `build_launch_wat` and the data-region preloading in
/// `build_vector_add_launch_wat`.
fn wat_data_literal(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 4);
    for b in bytes {
        s.push_str(&format!("\\{:02x}", b));
    }
    s
}

/// Build a WAT module specialised for the `vector_add_*` e2e tests.
///
/// Unlike [`build_launch_wat`], this helper:
///
/// 1. Preloads three pointer-arg data regions in addition to the argv
///    buffer (so the kernel reads real `a`, `b` bytes and writes into a
///    zeroed `c` region — all inside Wasm linear memory).
/// 2. Parameterises the grid/block dims (the canonical `vector_add`
///    fixture uses `grid_x = 1, block_x = 64`, not `1x1x1`).
/// 3. Exports the linear memory unchanged, so the test can read the
///    `c` region back out of `instance.get_memory(..)` after the
///    launch synchronizes.
///
/// We avoid extending `build_launch_wat` itself so the existing five
/// argv-shape tests (which all use a single data segment + fixed 1x1x1
/// dims) keep their current footprint.
#[allow(clippy::too_many_arguments)]
fn build_vector_add_launch_wat(
    argv_bytes: &[u8],
    argv_offset: usize,
    kernel_id: u64,
    grid_x: u32,
    block_x: u32,
    data_regions: &[(usize, Vec<u8>)],
) -> String {
    let argv_literal = wat_data_literal(argv_bytes);
    let mut data_segments = String::new();
    for (offset, bytes) in data_regions {
        let literal = wat_data_literal(bytes);
        data_segments.push_str(&format!(
            "\n          (data (i32.const {offset}) \"{literal}\")"
        ));
    }
    format!(
        r#"
        (module
          (import "{m}" "{fn_name}"
              (func $launch (param i64 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32)))
          (memory (export "memory") 4)
          (data (i32.const {argv_offset}) "{argv_literal}"){data_segments}
          (func (export "launch_with_args") (result i32)
            (call $launch
              (i64.const {kernel_id})
              (i32.const {grid_x}) (i32.const 1) (i32.const 1)
              (i32.const {block_x}) (i32.const 1) (i32.const 1)
              (i32.const 0)
              (i32.const {argv_offset})
              (i32.const {argv_len}))))
        "#,
        m = MODULE,
        fn_name = FN_LAUNCH,
        argv_offset = argv_offset,
        argv_len = argv_bytes.len(),
        argv_literal = argv_literal,
        data_segments = data_segments,
        kernel_id = kernel_id,
        grid_x = grid_x,
        block_x = block_x,
    )
}

/// Register a kernel backed by a real PTX module.
///
/// Mirrors [`register_stub_kernel`] but uses `cust::module::Module::from_ptx`
/// to compile the supplied PTX bytes through the CUDA driver's JIT.
/// Requires `--features cuda`; without the feature the helper panics
/// because the no-CUDA build cannot load a real module at all.
///
/// Returns `Ok((kernel_id, ptx_load_succeeded))`. When
/// `ptx_load_succeeded` is `false`, the JIT compile inside cust rejected
/// the PTX (typically on a host whose compute capability is below the
/// PTX's `.target sm_XX` line); the test should treat that as a skip
/// rather than a failure. In that case the returned kernel id is from a
/// `module: None` stub fall-back, so the caller's subsequent launch
/// observes the `InvalidKernel` failure path instead of marshalling
/// success.
/// Initialise CUDA exactly once for this test process and keep the primary
/// context alive for the whole run.
///
/// `cust::quick_init` calls `cuInit` and pushes a primary context; calling it
/// twice in one process fails ("context already exists"), so the `Context` is
/// cached in a `OnceLock`. Both `register_real_kernel` (which needs a current
/// context for `Module::from_ptx`) and `device_compute_capability` (which needs
/// `cuInit` to have run before any `cuDevice*` call) go through here — the
/// latter previously failed with `CUDA_ERROR_NOT_INITIALIZED` because it queried
/// the device before anything initialised the driver.
#[cfg(feature = "cuda")]
fn ensure_cuda_initialized() {
    // Route through the SHARED primary-context helper the launch path uses, and
    // re-bind on EVERY call. libtest runs each test on its own thread and the
    // CUDA current-context is thread-local, so module-load (`from_ptx`) here and
    // the launch's stream/dispatch must both bind the same device-0 primary
    // context on whatever thread they run on. Using a private `cust::quick_init`
    // + `OnceLock<Context>` instead made the context current only on the FIRST
    // thread to call it — which is why the launch test passed in isolation but
    // failed `InvalidContext` once another test initialised CUDA first in a
    // full-file run. `ensure_current_context` re-retains + `cuCtxSetCurrent`s the
    // live primary context every call, so each test thread is self-sufficient.
    tensor_wasm_wasi_gpu::cuda_ctx::ensure_current_context()
        .expect("ensure_cuda_initialized: ensure_current_context");
}

#[cfg(feature = "cuda")]
fn register_real_kernel(
    ctx: &WasiCudaContext,
    owner: InstanceId,
    name: &str,
    ptx_bytes: &[u8],
) -> (u64, bool) {
    // `cust::module::Module::from_ptx` requires a current CUDA context; the
    // wasi-cuda launch path inherits whatever context is current on this
    // thread. Initialise once (and keep the primary context alive) via the
    // shared helper.
    ensure_cuda_initialized();

    let ptx_str = std::str::from_utf8(ptx_bytes).expect("PTX is valid UTF-8");
    let module = match cust::module::Module::from_ptx(ptx_str, &[]) {
        Ok(m) => Some(std::sync::Arc::new(m)),
        Err(e) => {
            eprintln!(
                "register_real_kernel: Module::from_ptx rejected `{name}` PTX \
                 ({e:?}); falling back to module:None. The host launch path \
                 will surface InvalidKernel, which the caller can treat as a \
                 SM-mismatch skip."
            );
            None
        }
    };
    let loaded = module.is_some();
    let kid = ctx
        .registry
        .register(KernelEntry {
            owner,
            entry: name.into(),
            ptx_bytes_len: ptx_bytes.len(),
            module,
        })
        .expect("register");
    (kid.0, loaded)
}

#[cfg(not(feature = "cuda"))]
#[allow(dead_code)]
fn register_real_kernel(
    _ctx: &WasiCudaContext,
    _owner: InstanceId,
    _name: &str,
    _ptx_bytes: &[u8],
) -> (u64, bool) {
    panic!(
        "register_real_kernel requires --features cuda; \
         the no-CUDA build has no way to load a real PTX module."
    );
}

/// The current CUDA device's compute capability as `(major, minor)`.
///
/// Used by the end-to-end launch test to pick a PTX fixture whose `.target`
/// the driver JIT will accept on THIS device, rather than feeding the JIT a
/// known-mismatched fixture (which fails and leaves a sticky error on the
/// context, poisoning the next load — the `InvalidPtx`-then-`UnknownError`
/// cascade observed on the RTX 2060 / sm_75 dev box). Requires an initialised
/// CUDA context (the caller runs `cust::quick_init` first).
#[cfg(feature = "cuda")]
fn device_compute_capability() -> (i32, i32) {
    use cust::sys as cuda_sys;
    // Make sure `cuInit` has run before any `cuDevice*` call — otherwise the
    // driver returns `CUDA_ERROR_NOT_INITIALIZED`. Idempotent and cached.
    ensure_cuda_initialized();
    // The raw driver API is now usable. We go through `cust::sys`
    // directly rather than the safe `Device::get_attribute` wrapper because
    // the latter is not exposed under our `cust` feature set
    // (`default-features = false`); the raw FFI is always linked.
    // SAFETY: out-params are valid locals; the driver is initialised.
    unsafe {
        let mut dev: cuda_sys::CUdevice = 0;
        let res = cuda_sys::cuDeviceGet(&mut dev as *mut cuda_sys::CUdevice, 0);
        assert_eq!(
            res,
            cuda_sys::cudaError_enum::CUDA_SUCCESS,
            "cuDeviceGet(0) failed: {res:?}"
        );
        let mut major = 0i32;
        let mut minor = 0i32;
        let r_major = cuda_sys::cuDeviceGetAttribute(
            &mut major as *mut i32,
            cuda_sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MAJOR,
            dev,
        );
        let r_minor = cuda_sys::cuDeviceGetAttribute(
            &mut minor as *mut i32,
            cuda_sys::CUdevice_attribute_enum::CU_DEVICE_ATTRIBUTE_COMPUTE_CAPABILITY_MINOR,
            dev,
        );
        assert_eq!(
            r_major,
            cuda_sys::cudaError_enum::CUDA_SUCCESS,
            "cuDeviceGetAttribute(COMPUTE_CAPABILITY_MAJOR) failed: {r_major:?}"
        );
        assert_eq!(
            r_minor,
            cuda_sys::cudaError_enum::CUDA_SUCCESS,
            "cuDeviceGetAttribute(COMPUTE_CAPABILITY_MINOR) failed: {r_minor:?}"
        );
        (major, minor)
    }
}

/// Pick the `vector_add` PTX fixture whose `.target` JIT-loads on the current
/// device: the sm_75 build for compute capability < 8.0 (Turing and older),
/// the canonical sm_80 build otherwise. sm_75 PTX would also JIT forward onto
/// Ampere+, but preferring the exact-arch fixture keeps the proof honest about
/// what it exercised.
#[cfg(feature = "cuda")]
fn select_vector_add_ptx() -> (&'static [u8], &'static str) {
    let (major, minor) = device_compute_capability();
    if major >= 8 {
        (VECTOR_ADD_PTX, "sm_80")
    } else {
        eprintln!(
            "select_vector_add_ptx: device compute capability {major}.{minor} < 8.0; \
             using the sm_75 fixture."
        );
        (VECTOR_ADD_PTX_SM75, "sm_75")
    }
}

/// Scalar argv (mix of i32, i64, f32, f64, u32, u64) round-trips through
/// the launch path. On the no-CUDA host stub the launch returns
/// `NotAvailable` because no GPU is available, but the parsed args are
/// recorded into `last_lowered_args` — that's the property the test
/// pins.
#[tokio::test]
async fn scalar_argv_round_trips_through_launch_path() {
    let (engine, linker) = make_engine_and_linker();
    let owner = InstanceId(301);
    let mut ctx = WasiCudaContext::new(owner);
    ctx.enable_wasi_cuda();
    let kid = register_stub_kernel(&ctx, owner, "scalar_kernel");

    let expected = vec![
        LoweredArg::I32(-13),
        LoweredArg::I64(1_234_567_890_123),
        LoweredArg::F32(2.5_f32),
        LoweredArg::F64(3.5_f64),
        LoweredArg::U32(0x1234_5678),
        LoweredArg::U64(0xDEAD_BEEF_C0FF_EE00),
    ];
    let argv = encode_argv(&expected);
    let wat = build_launch_wat(&argv, 1024, kid);
    let wasm = wat::parse_str(&wat).unwrap();
    let module = wasmtime::Module::new(&engine, &wasm).expect("compile");

    let mut store = wasmtime::Store::new(&engine, TestStore { cuda: ctx });
    let instance = linker
        .instantiate_async(&mut store, &module)
        .await
        .expect("instantiate");
    let f = instance
        .get_typed_func::<(), i32>(&mut store, "launch_with_args")
        .expect("typed func");
    let rc = f.call_async(&mut store, ()).await.expect("call");

    // No-CUDA path: launch reports NotAvailable after parsing argv.
    // CUDA path: the stub kernel has no PTX module behind it (see
    // register_stub_kernel), so the launch correctly fails with
    // InvalidKernel rather than the optimistic `0` the test originally
    // claimed. The argv-parsing assertion below is the real property the
    // test pins. A real-PTX launch-success assertion lives in the
    // #[ignore = "requires CUDA hardware"] test at the bottom of the file.
    #[cfg(not(feature = "cuda"))]
    assert_eq!(
        rc,
        AbiError::NotAvailable.code(),
        "no-CUDA host: launch reports NotAvailable after parsing argv"
    );
    #[cfg(feature = "cuda")]
    assert_eq!(
        rc,
        AbiError::InvalidKernel.code(),
        "CUDA host: stub kernel has no PTX module; launch rejects with InvalidKernel"
    );

    // The parsed args must be visible regardless of CUDA-vs-stub.
    // `last_lowered_args` returns a pointer-free snapshot, so compare
    // against the snapshot projection of the expected `LoweredArg`s.
    let recorded = store.data().wasi_cuda().last_lowered_args();
    let expected_snapshots: Vec<LoweredArgSnapshot> =
        expected.iter().map(LoweredArgSnapshot::from).collect();
    assert_eq!(
        recorded, expected_snapshots,
        "parsed argv must round-trip the original LoweredArg sequence"
    );
}

/// Pointer argv (two pointer args, mixed with a scalar) round-trips
/// through the launch path. The pointer regions all lie inside the
/// guest's linear memory, so the bounds-check inside `parse_argv`
/// passes and the resolved host pointers are recorded.
#[tokio::test]
async fn pointer_argv_round_trips_through_launch_path() {
    let (engine, linker) = make_engine_and_linker();
    let owner = InstanceId(302);
    let mut ctx = WasiCudaContext::new(owner);
    ctx.enable_wasi_cuda();
    let kid = register_stub_kernel(&ctx, owner, "pointer_kernel");

    // Two pointer args: one at offset 256 (length 64), one at offset
    // 4096 (length 128); both live inside the 4-page (256 KiB) memory
    // the WAT exports. A scalar i32 separates them to confirm mixed
    // argv works.
    //
    // `LoweredArg::Ptr` carries a crate-private `host_ptr` so out-of-
    // crate callers go through `LoweredArg::ptr_for_encoding`, which
    // produces the null-pointer placeholder `encode_argv` ignores.
    let expected = vec![
        LoweredArg::ptr_for_encoding(256, 64),
        LoweredArg::I32(7),
        LoweredArg::ptr_for_encoding(4096, 128),
    ];
    let argv = encode_argv(&expected);
    // Place the argv buffer high enough that it doesn't overlap the
    // pointer-arg regions we declared above.
    let wat = build_launch_wat(&argv, 8192, kid);
    let wasm = wat::parse_str(&wat).unwrap();
    let module = wasmtime::Module::new(&engine, &wasm).expect("compile");

    let mut store = wasmtime::Store::new(&engine, TestStore { cuda: ctx });
    let instance = linker
        .instantiate_async(&mut store, &module)
        .await
        .expect("instantiate");
    let f = instance
        .get_typed_func::<(), i32>(&mut store, "launch_with_args")
        .expect("typed func");
    let rc = f.call_async(&mut store, ()).await.expect("call");

    // Same reasoning as scalar_argv_round_trips_through_launch_path: the
    // CUDA path correctly rejects the stub kernel with InvalidKernel
    // because no PTX module is loaded. The argv-parsing assertion below
    // is the property the test pins.
    #[cfg(not(feature = "cuda"))]
    assert_eq!(rc, AbiError::NotAvailable.code());
    #[cfg(feature = "cuda")]
    assert_eq!(rc, AbiError::InvalidKernel.code());

    // `last_lowered_args` returns pointer-free snapshots — the resolved
    // `host_ptr` lives only on the crate-internal launch path and is
    // intentionally redacted at the public boundary (see the
    // `LoweredArgSnapshot` rationale).
    let recorded = store.data().wasi_cuda().last_lowered_args();
    assert_eq!(recorded.len(), 3, "expected three lowered args");
    // Spot-check fields. Pointers compare by `guest_offset` / `len`
    // only — the pointer itself is no longer part of the snapshot.
    match &recorded[0] {
        LoweredArgSnapshot::Ptr { guest_offset, len } => {
            assert_eq!(*guest_offset, 256);
            assert_eq!(*len, 64);
        }
        other => panic!("idx 0 expected Ptr, got {other:?}"),
    }
    assert!(matches!(recorded[1], LoweredArgSnapshot::I32(7)));
    match &recorded[2] {
        LoweredArgSnapshot::Ptr { guest_offset, len } => {
            assert_eq!(*guest_offset, 4096);
            assert_eq!(*len, 128);
        }
        other => panic!("idx 2 expected Ptr, got {other:?}"),
    }
}

/// An out-of-bounds pointer arg must surface as `InvalidPointer`. The
/// outer args-region bounds-check passes (the argv buffer itself is
/// in-bounds); the parser then bounds-checks the embedded guest
/// pointer and fails.
#[tokio::test]
async fn pointer_argv_out_of_bounds_returns_invalid_pointer() {
    let (engine, linker) = make_engine_and_linker();
    let owner = InstanceId(303);
    let mut ctx = WasiCudaContext::new(owner);
    ctx.enable_wasi_cuda();
    let kid = register_stub_kernel(&ctx, owner, "oob_ptr_kernel");

    // Guest pointer at offset (4 pages == 256 KiB) is past the memory
    // end (we export 4 pages here, so the highest valid offset for a
    // zero-length pointer is exactly 256 KiB, but a 16-byte read at
    // offset 250000 spans into OOB).
    //
    // `LoweredArg::Ptr` is `#[non_exhaustive]`, so out-of-crate callers
    // (including this integration test) cannot construct via struct-
    // literal syntax — they go through `ptr_for_encoding`, which stamps
    // a null host_ptr placeholder the parser subsequently overwrites.
    let expected = vec![LoweredArg::ptr_for_encoding(
        /* guest_offset */ 4 * 65536 - 256, // straddles end of 4 pages
        /* len */ 1024,
    )];
    let argv = encode_argv(&expected);
    let wat = build_launch_wat(&argv, 1024, kid);
    let wasm = wat::parse_str(&wat).unwrap();
    let module = wasmtime::Module::new(&engine, &wasm).expect("compile");

    let mut store = wasmtime::Store::new(&engine, TestStore { cuda: ctx });
    let instance = linker
        .instantiate_async(&mut store, &module)
        .await
        .expect("instantiate");
    let f = instance
        .get_typed_func::<(), i32>(&mut store, "launch_with_args")
        .expect("typed func");
    let rc = f.call_async(&mut store, ()).await.expect("call");
    assert_eq!(
        rc,
        AbiError::InvalidPointer.code(),
        "pointer arg whose [ptr, ptr+len) spans past memory end must \
         return InvalidPointer (got {rc})"
    );
    // No args should have been recorded because parsing failed.
    let recorded = store.data().wasi_cuda().last_lowered_args();
    assert!(
        recorded.is_empty(),
        "last_lowered_args must remain empty on parse failure"
    );
}

/// CUDA hardware variant: a real launch of a kernel taking scalar args
/// returns 0. The launch fixture loads a PTX module that adds two
/// integers and writes the result into a UVM buffer; the host checks
/// the buffer after the launch returns.
///
/// The body is `#[ignore]` because it requires a CUDA-capable host;
/// on no-CUDA CI the test compiles cleanly thanks to the cfg-gated
/// CUDA-only steps.
#[tokio::test]
#[ignore = "requires CUDA hardware"]
async fn scalar_argv_real_cuda_launch() {
    // The no-CUDA fallback path still exercises the parser end-to-end;
    // the CUDA-only step is the kernel-runs-and-mutates-output check.
    let (engine, linker) = make_engine_and_linker();
    let owner = InstanceId(401);
    let mut ctx = WasiCudaContext::new(owner);
    ctx.enable_wasi_cuda();
    let kid = register_stub_kernel(&ctx, owner, "scalar_add");
    let argv = encode_argv(&[LoweredArg::I32(2), LoweredArg::I32(3)]);
    let wat = build_launch_wat(&argv, 1024, kid);
    let wasm = wat::parse_str(&wat).unwrap();
    let module = wasmtime::Module::new(&engine, &wasm).expect("compile");
    let mut store = wasmtime::Store::new(&engine, TestStore { cuda: ctx });
    let instance = linker
        .instantiate_async(&mut store, &module)
        .await
        .expect("instantiate");
    let f = instance
        .get_typed_func::<(), i32>(&mut store, "launch_with_args")
        .expect("typed func");
    let _rc = f.call_async(&mut store, ()).await.expect("call");
    #[cfg(feature = "cuda")]
    {
        // TODO(v0.2-cuda-runner): wire a real PTX fixture + UVM output
        // buffer here and assert the kernel actually ran. The runtime
        // path is exercised by the shipping `register_stub_kernel`
        // entry having `module: None`, so under `--features cuda` the
        // launch will fail with `InvalidKernel` until the runner ships
        // a real PTX-backed module.
    }
}

/// CUDA hardware variant: a real launch of a kernel taking pointer
/// args returns 0. Pointer args are resolved against the guest's linear
/// memory; under CUDA Unified Memory the resolved host pointers double
/// as device pointers.
#[tokio::test]
#[ignore = "requires CUDA hardware"]
async fn pointer_argv_real_cuda_launch() {
    let (engine, linker) = make_engine_and_linker();
    let owner = InstanceId(402);
    let mut ctx = WasiCudaContext::new(owner);
    ctx.enable_wasi_cuda();
    let kid = register_stub_kernel(&ctx, owner, "pointer_copy");
    let argv = encode_argv(&[
        LoweredArg::ptr_for_encoding(0, 1024),
        LoweredArg::ptr_for_encoding(2048, 1024),
        LoweredArg::U32(256),
    ]);
    let wat = build_launch_wat(&argv, 8192, kid);
    let wasm = wat::parse_str(&wat).unwrap();
    let module = wasmtime::Module::new(&engine, &wasm).expect("compile");
    let mut store = wasmtime::Store::new(&engine, TestStore { cuda: ctx });
    let instance = linker
        .instantiate_async(&mut store, &module)
        .await
        .expect("instantiate");
    let f = instance
        .get_typed_func::<(), i32>(&mut store, "launch_with_args")
        .expect("typed func");
    let _rc = f.call_async(&mut store, ()).await.expect("call");
    #[cfg(feature = "cuda")]
    {
        // TODO(v0.2-cuda-runner): wire a real PTX fixture and assert the
        // kernel copied the data. See sibling TODO in
        // `scalar_argv_real_cuda_launch`.
    }
}

/// Canonical `kernels/vector_add.ptx` fixture, embedded at compile time.
///
/// Shared by the two v0.3.3 end-to-end tests below. Embedding via
/// `include_bytes!` (rather than `std::fs::read` at runtime) means the
/// test binary is self-contained — no path-relative `read` that breaks
/// when `cargo test` is invoked from outside the workspace root.
#[cfg(feature = "cuda")]
const VECTOR_ADD_PTX: &[u8] = include_bytes!("fixtures/vector_add.ptx");

/// sm_75 (Turing) variant of the vector_add kernel — same body, lower
/// `.target` — so the launch actually runs on sub-Ampere dev boxes (e.g. the
/// RTX 2060, compute capability 7.5) where the sm_80 fixture is rejected by
/// the driver JIT. The end-to-end test falls back to this when the canonical
/// sm_80 PTX is refused, rather than skipping the kernel-output assertion.
#[cfg(feature = "cuda")]
const VECTOR_ADD_PTX_SM75: &[u8] = include_bytes!("fixtures/vector_add_sm75.ptx");

/// End-to-end Wasm -> wasi-cuda -> `cuLaunchKernel` -> result-readback
/// proof on real CUDA. Closes the v0.3.2 audit Problem #14.
///
/// Pipeline this test exercises:
///
/// 1. Build a Wasm guest that exports a `memory` of 4 pages and
///    pre-populates three regions: `a[i] = i as f32`, `b[i] = 100 + i`,
///    `c[i] = 0`, for `i in 0..64`. The guest also writes a tagged
///    argv buffer holding `[Ptr(a, 256), Ptr(b, 256), Ptr(c, 256),
///    U32(64)]` and calls `wasi_cuda_launch` with `grid_x = 1,
///    block_x = 64`.
/// 2. Register the real `kernels/vector_add.ptx` through
///    [`register_real_kernel`], which compiles the PTX via
///    `cust::module::Module::from_ptx`. On a host whose compute
///    capability is below the PTX's `.target sm_80` line the JIT may
///    reject the module — the helper falls back to `module: None` and
///    the test skips downstream assertions.
/// 3. Invoke `launch_with_args`. The host marshals the argv into a
///    `void**` and calls `cuLaunchKernel`, then `stream.synchronize`s.
/// 4. Read the `c` region back out of Wasm linear memory and assert
///    `c[i] == a[i] + b[i] == 100.0 + 2.0 * (i as f32)` for all `i`.
///
/// The pointer-arg path relies on the wasm linear-memory backing
/// doubling as a device-addressable pointer; with `--features
/// unified-memory` on `tensor-wasm-mem` (the production default), the
/// linear memory IS `cuMemAllocManaged` and the kernel writes through
/// the same address the host reads back. Without unified memory the
/// host pointer is plain heap and `cuLaunchKernel` will fault — that
/// constraint is documented in `docs/RISKS.md` and is why the test
/// stays `#[ignore]`d outside the v0.4 self-hosted runner config.
///
/// The body compiles cleanly on no-CUDA hosts (every CUDA-specific
/// step is `#[cfg(feature = "cuda")]`-gated); the test simply returns
/// early.
#[tokio::test]
#[ignore = "requires CUDA hardware"]
async fn vector_add_end_to_end_real_ptx_real_kernel() {
    #[cfg(not(feature = "cuda"))]
    {
        // Compile-time only: the no-CUDA build cannot load a real PTX
        // module, so the body short-circuits. The test still benefits
        // from being compiled on no-CUDA hosts (catches drift in the
        // helper signatures, the `include_bytes!` path, the WAT
        // template, etc.).
        eprintln!(
            "vector_add_end_to_end_real_ptx_real_kernel: skipping (built without --features cuda)"
        );
    }

    #[cfg(feature = "cuda")]
    {
        const N: usize = 64;
        const ELEM_BYTES: usize = std::mem::size_of::<f32>();
        const REGION_BYTES: usize = N * ELEM_BYTES; // 256

        // Offsets inside the guest's 4-page (256 KiB) linear memory.
        // The three data regions are well-separated and well below the
        // argv buffer offset, so a `data` segment never overlaps another.
        const A_OFFSET: usize = 1024;
        const B_OFFSET: usize = 2048;
        const C_OFFSET: usize = 3072;
        const ARGV_OFFSET: usize = 8192;

        let mut a_bytes = Vec::with_capacity(REGION_BYTES);
        let mut b_bytes = Vec::with_capacity(REGION_BYTES);
        let mut c_bytes = Vec::with_capacity(REGION_BYTES);
        for i in 0..N {
            a_bytes.extend_from_slice(&(i as f32).to_le_bytes());
            b_bytes.extend_from_slice(&(100.0_f32 + i as f32).to_le_bytes());
            c_bytes.extend_from_slice(&0.0_f32.to_le_bytes());
        }

        // Finding #8: back guest linear memory with cuMemAllocManaged so the
        // kernel pointer-args (host addresses inside linear memory) are
        // device-addressable; a plain-host-heap engine fails the launch with
        // CUDA_ERROR_INVALID_VALUE.
        let (engine, linker) = make_managed_engine_and_linker();
        let owner = InstanceId(403);
        let mut ctx = WasiCudaContext::new(owner);
        ctx.enable_wasi_cuda();
        // Pick the fixture whose `.target` the driver JIT accepts on THIS
        // device. We must NOT try a known-mismatched fixture first: a failed
        // `Module::from_ptx` leaves a sticky error on the CUDA context that
        // poisons the next load (the observed `InvalidPtx`-then-`UnknownError`
        // cascade on the sm_75 dev box). `register_real_kernel` calls
        // `quick_init` before this runs, so a context is current.
        let (ptx, arch) = select_vector_add_ptx();
        let (kid, loaded) = register_real_kernel(&ctx, owner, "vector_add", ptx);
        if !loaded {
            // The arch-matched fixture failed to load — that is a real
            // regression on a supported device, not a skip. Surface it.
            panic!(
                "vector_add_end_to_end_real_ptx_real_kernel: the {arch} fixture \
                 (arch-matched to this device) was rejected by the driver JIT; \
                 last error: {:?}",
                ctx.last_error()
            );
        }

        let argv = encode_argv(&[
            LoweredArg::ptr_for_encoding(A_OFFSET as u32, REGION_BYTES as u32),
            LoweredArg::ptr_for_encoding(B_OFFSET as u32, REGION_BYTES as u32),
            LoweredArg::ptr_for_encoding(C_OFFSET as u32, REGION_BYTES as u32),
            LoweredArg::U32(N as u32),
        ]);
        let wat = build_vector_add_launch_wat(
            &argv,
            ARGV_OFFSET,
            kid,
            /* grid_x */ 1,
            /* block_x */ N as u32,
            &[
                (A_OFFSET, a_bytes.clone()),
                (B_OFFSET, b_bytes.clone()),
                (C_OFFSET, c_bytes.clone()),
            ],
        );
        let wasm = wat::parse_str(&wat).expect("parse vector_add WAT");
        let module = wasmtime::Module::new(&engine, &wasm).expect("compile vector_add WAT");
        let mut store = wasmtime::Store::new(&engine, TestStore { cuda: ctx });
        let instance = linker
            .instantiate_async(&mut store, &module)
            .await
            .expect("instantiate");
        let f = instance
            .get_typed_func::<(), i32>(&mut store, "launch_with_args")
            .expect("typed func");
        let rc = f.call_async(&mut store, ()).await.expect("call");
        assert_eq!(
            rc,
            0,
            "vector_add launch must return 0 on a CUDA host with the SM_80 \
             fixture; last error: {:?}",
            store.data().wasi_cuda().last_error()
        );

        // Read the `c` region back out of the guest's linear memory.
        // The kernel wrote through the same `host_ptr` the marshaller
        // resolved against `mem.data(..)`, so the same byte range is
        // observable here.
        let memory = instance.get_memory(&mut store, "memory").expect("memory");
        let mut readback = vec![0u8; REGION_BYTES];
        memory
            .read(&store, C_OFFSET, &mut readback)
            .expect("read c region");

        // Decode and assert. This is the property the test pins: the
        // kernel ran, addressed the correct guest-memory window, and
        // produced the canonical element-wise sum. Any off-by-one in
        // the argv marshalling (wrong offset, wrong length, scalar/
        // pointer tag confusion) breaks this check, as does any
        // failure to surface the kernel's writes back to the host.
        for i in 0..N {
            let off = i * ELEM_BYTES;
            let mut buf = [0u8; ELEM_BYTES];
            buf.copy_from_slice(&readback[off..off + ELEM_BYTES]);
            let got = f32::from_le_bytes(buf);
            let want = 100.0_f32 + 2.0_f32 * (i as f32);
            assert_eq!(
                got, want,
                "c[{i}] mismatch: kernel produced {got}, expected {want} \
                 (= 100 + 2*{i})"
            );
        }
    }
}

/// v0.3.3 commitment: the registry + load_ptx + launch surface
/// accepts the real `kernels/vector_add.ptx` bytes and routes them
/// through the dispatch path on every CUDA-capable host — including
/// dev boxes whose compute capability is below the SM_80 the
/// canonical fixture targets.
///
/// The test is intentionally *unignored*. It is the weaker sibling of
/// [`vector_add_end_to_end_real_ptx_real_kernel`]: rather than
/// asserting kernel output is correct, it asserts that the marshalling
/// layer accepts the call and the failure (if any) comes from
/// downstream CUDA — never from the host's argv parser or pointer
/// bounds-check.
///
/// Specifically, on a CUDA host the launch must return ONE OF:
/// - `0` (Ok) — happy path on SM_80+ runners.
/// - `MalformedPtx` — the JIT compile inside `cust::module::Module::from_ptx`
///   rejected the PTX (this happens on the RTX 2060 dev box: PTX
///   `.target sm_80` against compute-cap 7.5). In this case the
///   helper returned `loaded == false` and the kernel was registered
///   with `module: None`, so the launch surfaces `InvalidKernel`.
/// - `InvalidKernel` — same as above, propagated from the launch path.
/// - `LaunchFailed` — `cuLaunchKernel` itself rejected the launch
///   (e.g. param-count mismatch detected by the driver, or
///   sm-version mismatch caught at launch time rather than load time).
///
/// The launch must NEVER return `InvalidArgs` or `InvalidPointer` —
/// those would indicate the host's typed-argv marshalling regressed
/// before the call ever reached the CUDA driver.
///
/// On the no-CUDA path the assertion degrades to `NotAvailable`,
/// which is the existing no-CUDA contract.
#[tokio::test]
async fn dispatch_pipeline_compiles_against_real_module_bytes() {
    const N: usize = 64;
    const ELEM_BYTES: usize = std::mem::size_of::<f32>();
    const REGION_BYTES: usize = N * ELEM_BYTES;
    const A_OFFSET: usize = 1024;
    const B_OFFSET: usize = 2048;
    const C_OFFSET: usize = 3072;
    const ARGV_OFFSET: usize = 8192;

    let mut a_bytes = Vec::with_capacity(REGION_BYTES);
    let mut b_bytes = Vec::with_capacity(REGION_BYTES);
    let c_bytes = vec![0u8; REGION_BYTES];
    for i in 0..N {
        a_bytes.extend_from_slice(&(i as f32).to_le_bytes());
        b_bytes.extend_from_slice(&(100.0_f32 + i as f32).to_le_bytes());
    }

    let (engine, linker) = make_engine_and_linker();
    let owner = InstanceId(404);
    let mut ctx = WasiCudaContext::new(owner);
    ctx.enable_wasi_cuda();

    #[cfg(feature = "cuda")]
    let kid = {
        // Use the arch-matched fixture (sm_75 on Turing, sm_80 on Ampere+).
        // Feeding the sm_80 fixture to an sm_75 driver JIT fails `from_ptx`,
        // and that failed load poisons the process-shared CUDA context for the
        // later `vector_add_end_to_end_real_ptx_real_kernel` (a real launch).
        // Arch-matching here keeps every real `from_ptx` in the suite loadable.
        let (id, _loaded) =
            register_real_kernel(&ctx, owner, "vector_add", select_vector_add_ptx().0);
        // We deliberately do NOT branch on `_loaded` here: the whole
        // point of this test is to prove the dispatch path tolerates
        // a real PTX module regardless of whether the local GPU can
        // JIT it. A failed JIT compile leaves `module: None`, so the
        // launch surfaces `InvalidKernel` — which is one of the
        // allowed outcomes below.
        id
    };
    #[cfg(not(feature = "cuda"))]
    let kid = register_stub_kernel(&ctx, owner, "vector_add");

    let argv = encode_argv(&[
        LoweredArg::ptr_for_encoding(A_OFFSET as u32, REGION_BYTES as u32),
        LoweredArg::ptr_for_encoding(B_OFFSET as u32, REGION_BYTES as u32),
        LoweredArg::ptr_for_encoding(C_OFFSET as u32, REGION_BYTES as u32),
        LoweredArg::U32(N as u32),
    ]);
    let wat = build_vector_add_launch_wat(
        &argv,
        ARGV_OFFSET,
        kid,
        /* grid_x */ 1,
        /* block_x */ N as u32,
        &[
            (A_OFFSET, a_bytes),
            (B_OFFSET, b_bytes),
            (C_OFFSET, c_bytes),
        ],
    );
    let wasm = wat::parse_str(&wat).expect("parse vector_add WAT");
    let module = wasmtime::Module::new(&engine, &wasm).expect("compile vector_add WAT");
    let mut store = wasmtime::Store::new(&engine, TestStore { cuda: ctx });
    let instance = linker
        .instantiate_async(&mut store, &module)
        .await
        .expect("instantiate");
    let f = instance
        .get_typed_func::<(), i32>(&mut store, "launch_with_args")
        .expect("typed func");
    let rc = f.call_async(&mut store, ()).await.expect("call");

    #[cfg(not(feature = "cuda"))]
    {
        assert_eq!(
            rc,
            AbiError::NotAvailable.code(),
            "no-CUDA host: launch must report NotAvailable after parsing argv (got {rc})"
        );
    }

    #[cfg(feature = "cuda")]
    {
        // This test drives a real `vector_add` module with POINTER args, but
        // backs guest linear memory with the plain host-heap engine
        // (`make_engine_and_linker`) — NOT the unified-memory creator. That is
        // exactly the dangerous combination the host-side device-addressability
        // guard in `host::launch` refuses: those host-heap pointers are not
        // valid device addresses, so letting `cuLaunchKernel` run would raise a
        // sticky `CUDA_ERROR_ILLEGAL_ADDRESS` that poisons the process-shared
        // CUDA context (the root cause of the old full-file launch flake and a
        // cross-tenant DoS). So under `--features cuda` the launch must be
        // REFUSED, never succeed.
        //
        // Acceptable refusals:
        // - `LaunchFailed` — the guard rejected the non-managed pointer arg
        //   (the happy path for this test on any GPU), OR `cuLaunchKernel`
        //   itself rejected the launch.
        // - `MalformedPtx` / `InvalidKernel` — the module failed to JIT on this
        //   device (older driver), so the kernel was registered `module: None`
        //   and the launch never reached the guard.
        //
        // `0` (success) is explicitly forbidden: it would mean a launch against
        // non-device-addressable memory was allowed through. `InvalidArgs` /
        // `InvalidPointer` are forbidden too — those would mean the typed-argv
        // marshalling regressed before the call reached the driver.
        assert_ne!(
            rc,
            0,
            "a real-module pointer-arg launch against NON-managed (host-heap) \
             linear memory must be refused by the device-addressability guard, \
             not silently succeed (would risk a context-poisoning \
             CUDA_ERROR_ILLEGAL_ADDRESS). last_error: {:?}",
            store.data().wasi_cuda().last_error()
        );
        let allowed = [
            AbiError::MalformedPtx.code(),
            AbiError::InvalidKernel.code(),
            AbiError::LaunchFailed.code(),
        ];
        assert!(
            allowed.contains(&rc),
            "CUDA host: launch return code must be one of {allowed:?} \
             (MalformedPtx / InvalidKernel / LaunchFailed); got {rc}. \
             Anything else (InvalidArgs / InvalidPointer / NotAvailable) \
             indicates the typed-argv marshalling regressed before the call \
             reached the CUDA driver. last_error: {:?}",
            store.data().wasi_cuda().last_error()
        );
    }

    // The argv must have been parsed regardless of the downstream
    // outcome — that is the proof the marshalling layer accepted the
    // real-module-bytes shape.
    let recorded = store.data().wasi_cuda().last_lowered_args();
    assert_eq!(
        recorded.len(),
        4,
        "expected four lowered args (3 pointers + 1 scalar); got {}",
        recorded.len()
    );
    assert!(matches!(recorded[3], LoweredArgSnapshot::U32(n) if n == N as u32));
}

/// Regression: `build_kernel_param_storage` must lay out every slot
/// inside a single contiguous backing buffer.
///
/// The pre-v0.3.4 implementation boxed each scalar argument into its
/// own `Box<[u8]>`, costing one heap allocation per arg — 16 separate
/// allocations for a 16-arg kernel on the launch hot path. The fix
/// switched to a single `Vec<u8>` (frozen into a `Box<[u8]>`) plus a
/// pointer table indexing into it. This test pins the shape so a
/// future refactor that reintroduces per-arg boxing fails loudly: it
/// builds a 16-scalar argv (one of every supported scalar tag, plus
/// padding) and asserts every slot pointer lies inside the
/// `backing()` slice.
#[test]
fn build_kernel_param_storage_uses_single_backing_buffer() {
    // 16 scalars covering every tag the marshaller handles, with
    // duplicates of the wider types to push past the 16-arg threshold
    // the original allocation-explosion bug operated on.
    let args = vec![
        LoweredArg::I32(1),
        LoweredArg::I64(2),
        LoweredArg::F32(3.0),
        LoweredArg::F64(4.0),
        LoweredArg::U32(5),
        LoweredArg::U64(6),
        LoweredArg::I32(7),
        LoweredArg::I64(8),
        LoweredArg::F32(9.0),
        LoweredArg::F64(10.0),
        LoweredArg::U32(11),
        LoweredArg::U64(12),
        LoweredArg::I32(13),
        LoweredArg::I64(14),
        LoweredArg::F32(15.0),
        LoweredArg::F64(16.0),
    ];
    assert_eq!(args.len(), 16);

    let storage = build_kernel_param_storage(&args);
    assert_eq!(storage.len(), args.len());

    let backing = storage.backing();
    let slot_ptrs = storage.slot_ptrs();
    assert_eq!(slot_ptrs.len(), args.len(), "one pointer slot per argument");

    // Every slot pointer must land inside the contiguous `backing`
    // buffer. We compute the byte-offset between the slot pointer and
    // the start of `backing` and assert it is in `[0, backing.len())`.
    // A pre-v0.3.4 implementation would produce slots that point into
    // 16 disjoint heap allocations; their offsets would be either
    // outside the buffer's address range (unsigned wraparound to a
    // huge value) or simply unrelated to it.
    let base = backing.as_ptr() as usize;
    let end = base + backing.len();
    for (i, &slot) in slot_ptrs.iter().enumerate() {
        let slot_addr = slot as usize;
        assert!(
            slot_addr >= base && slot_addr < end,
            "slot {i} pointer {slot:p} is not inside backing buffer \
             [{base:#x}, {end:#x}) — per-arg boxing has regressed"
        );
    }

    // Sanity floor: a single backing buffer for 16 scalars should
    // comfortably fit inside one allocation worth of bytes. The exact
    // size depends on alignment padding, but it must be at least the
    // sum of the value bytes (4 + 8 + 4 + 8 + 4 + 8 = 36 bytes per
    // half × 2 halves = 72 bytes minimum, with padding making the real
    // total a bit larger). Keep the assertion loose so a benign
    // alignment-pad change does not flake the test.
    assert!(
        backing.len() >= 72,
        "backing buffer too small for 16 scalars; got {}",
        backing.len()
    );
}