tensor_wasm_jit/reject_list.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Reject-list detector for the Pliron `dialect-mir` lowering pipeline.
5//!
6//! [`scan_function`] walks a [`cranelift_codegen::ir::Function`] and returns
7//! every instruction whose opcode the wave-2 lowering cannot translate to
8//! the [`crate::lowered_ir::LoweredOp`] interim IR. The categories mirror
9//! the "Unsupported in v0.4" section of
10//! [`crate::pliron_dialect`](crate::pliron_dialect) one-for-one:
11//!
12//! - **Atomics** ([`RejectReason::Atomic`]): `atomic_load`, `atomic_store`,
13//! `atomic_rmw`, `atomic_cas`. Deferred — Wasm threads + GPU atomics is a
14//! memory-model alignment problem larger than the wave-2 scope.
15//! - **Strict-FP exception bits** ([`RejectReason::StrictFp`]):
16//! `fcvt_to_sint_sat`, `fcvt_to_uint_sat`. PTX default rounding diverges
17//! from Wasm-strict FP — these opcodes carry trap semantics the wave-2
18//! lowering cannot honour.
19//! - **Host calls** ([`RejectReason::HostCall`]): `call`, `call_indirect`,
20//! `return_call`, `return_call_indirect`. PTX has no host-callback path;
21//! wave 3+ will distinguish device-side `func.call`s from host
22//! round-trips, but for wave 2 every call is rejected.
23//!
24//! # Categories documented but absent from this Cranelift version
25//!
26//! The canonical reject list in [`crate::pliron_dialect`] also names:
27//!
28//! - **Wasm table ops** (`table.get` / `table.set`)
29//! - **Wasm GC / `ref.func`**
30//! - **`memory.grow` / `memory.size`**
31//! - **`memory.copy` / `memory.fill`**
32//!
33//! These are **not direct Cranelift opcodes in the pinned `0.111.9`
34//! workspace dependency**: they are intercepted by the Wasm-to-Cranelift
35//! translator (`cranelift-wasm` and Wasmtime), which lowers them into
36//! sequences of `load`/`store`/`call`-to-libcall before they reach the
37//! `ir::Function` this detector sees. The `call` reject above is therefore
38//! load-bearing: any host-routed lowering surfaces here as a `Call` and
39//! gets rejected on that ground. The dedicated [`RejectReason::TableOp`],
40//! [`RejectReason::GcOp`], [`RejectReason::MemoryResize`] and
41//! [`RejectReason::LargeMemcpy`] variants are pre-declared so the public
42//! API is stable when a future Cranelift bump (or a direct Wasm front-end)
43//! exposes those opcodes natively.
44//!
45//! # Why a separate pass
46//!
47//! The detector runs *before* the per-family `lower_*` modules. Returning
48//! an early `Vec<Rejection>` (rather than letting the lowerings fail one
49//! by one) is the contract that lets the auto-offload pipeline fall back
50//! to the blueprint detector cleanly: an admissible function gets the
51//! Pliron pipeline, an inadmissible one gets the legacy blueprint path —
52//! never a half-lowered, half-rejected mess.
53
54#![cfg(feature = "cuda-oxide-backend")]
55
56use std::collections::HashMap;
57
58use cranelift_codegen::ir::{Block, Function, Inst, Opcode};
59
60/// A reason a Cranelift instruction cannot be lowered to a
61/// [`crate::lowered_ir::LoweredOp`].
62///
63/// Each variant carries the offending opcode's mnemonic (`&'static str`)
64/// so error messages and logs are grep-able against the [mapping
65/// table](crate::pliron_dialect#mapping-table). The mnemonic is the same
66/// string Cranelift's own [`Opcode`] `Display` impl prints (e.g.
67/// `"atomic_rmw"`, not `"AtomicRmw"`), making it stable across the
68/// `cranelift-codegen` version bumps that periodically rename enum
69/// variants but keep the textual mnemonic.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum RejectReason {
72 /// Atomic memory operation. Deferred — Wasm-threads-on-GPU is a
73 /// memory-model problem out of scope for wave 2. Covers `atomic_load`,
74 /// `atomic_store`, `atomic_rmw`, `atomic_cas`.
75 Atomic(&'static str),
76
77 /// Floating-point opcode with strict-FP exception semantics PTX
78 /// default rounding cannot match. Covers `fcvt_to_sint_sat` and
79 /// `fcvt_to_uint_sat` — both saturate-on-out-of-range, behaviour the
80 /// PTX `cvt` does not provide by default. The full strict-FP scope
81 /// (NaN propagation, denormals-as-zero) is broader; wave 2 limits
82 /// itself to the trap-carrying conversions.
83 StrictFp(&'static str),
84
85 /// Wasm `table.get` / `table.set`. Tables live host-side; lowering
86 /// them would require device-resident table mirrors. Hard-rejected.
87 ///
88 /// **Not currently wired in the detector**: `cranelift-codegen` 0.111
89 /// has no direct `Opcode::TableGet` / `Opcode::TableSet` variant —
90 /// `cranelift-wasm` lowers these to `load`/`store`+libcall sequences
91 /// in the translator, so a Wasm module reaches us with the table
92 /// access already expanded into a `call` to a runtime helper (and
93 /// therefore is caught by [`RejectReason::HostCall`]). Pre-declared
94 /// here so a future direct Wasm front-end or a Cranelift bump that
95 /// introduces table opcodes natively does not have to reshuffle the
96 /// public enum.
97 TableOp(&'static str),
98
99 /// Wasm GC / reference-type opcode (`ref.func`, `ref.null`,
100 /// `ref.is_null` on a non-i31 ref). No device-side representation
101 /// possible. Hard-rejected.
102 ///
103 /// **Not currently wired** for the same reason as
104 /// [`RejectReason::TableOp`]: in `cranelift-codegen` 0.111 the GC
105 /// proposal opcodes either do not exist as direct enum variants
106 /// (`ref.func`) or are translated into other ops by `cranelift-wasm`
107 /// before the detector sees them. Pre-declared for forward
108 /// compatibility.
109 GcOp(&'static str),
110
111 /// `memory.grow` / `memory.size`. Linear-memory resizing requires a
112 /// host round-trip; PTX kernels must run with a fixed memory
113 /// snapshot.
114 ///
115 /// **Not currently wired**: `cranelift-codegen` 0.111 has no direct
116 /// `Opcode::MemoryGrow` / `Opcode::MemorySize` — Wasm `memory.grow`
117 /// reaches us as a libcall (a `Call` instruction targeting the
118 /// runtime helper) and is therefore caught by
119 /// [`RejectReason::HostCall`]. Pre-declared for the same forward-
120 /// compatibility reason as the other absent categories.
121 MemoryResize(&'static str),
122
123 /// `memory.copy` / `memory.fill` above the inline-copy threshold.
124 /// PTX has `cp.async.bulk` (sm_90+) but the wave-2 baseline is
125 /// sm_80 — the cuda-oxide PTX target version pinned via
126 /// [`crate::ptx_emit::DEFAULT_TARGET`](crate::ptx_emit::DEFAULT_TARGET).
127 ///
128 /// **Not currently wired**: same translator-expansion situation as
129 /// the other absent categories — `memory.copy` / `memory.fill` reach
130 /// us as `Call` instructions to the runtime, caught by
131 /// [`RejectReason::HostCall`]. Pre-declared for forward compatibility.
132 /// Wave 3+ will refine [`RejectReason::HostCall`] to distinguish
133 /// device-internal calls from runtime-helper calls and may at that
134 /// point route small `memory.copy` libcalls back to an inline
135 /// `Load`/`Store` sequence instead of a hard reject.
136 LargeMemcpy(&'static str),
137
138 /// `call` / `call_indirect` / `return_call` / `return_call_indirect`.
139 /// Host-callback prohibition: PTX has no path back into the Wasmtime
140 /// runtime, so every call is rejected at the wave-2 detector. Wave
141 /// 3+ will distinguish device-internal calls (legal: lowered to
142 /// `func.call`) from host round-trips (illegal: rejected).
143 HostCall(&'static str),
144
145 /// Any floating-point opcode (`fadd`, `fmul`, `fdiv`, `fma`, `sqrt`,
146 /// `fcmp`, the float conversions, …).
147 ///
148 /// jit MED fix (finding 4): the reject-list previously admitted
149 /// non-saturating FP ops. PTX default rounding (`add.rn.f32` etc.) is
150 /// bit-exact IEEE for the basic ops, but the broader strict-FP scope
151 /// (NaN payload propagation, denormal-as-zero behaviour, transcendental
152 /// approximations) is NOT yet proven equivalent to the Wasm/CPU
153 /// reference across this lowering path. Until a differential proof of
154 /// bit-exact rounding lands, ALL float opcodes are rejected so a float
155 /// function deopts to the CPU path rather than risk silent numerical
156 /// divergence. (The basic-op subset can be re-admitted once the
157 /// differential oracle proves equivalence — narrow this then.)
158 FloatOp(&'static str),
159
160 /// Integer divide / remainder (`sdiv`, `udiv`, `srem`, `urem`).
161 ///
162 /// jit MED fix (finding 4): Wasm `i32.div_u` etc. TRAP on divide-by-
163 /// zero (and signed div traps on `INT_MIN / -1` overflow). PTX `div`
164 /// has undefined behaviour on divide-by-zero — it does not trap. Until
165 /// the lowering emits an explicit divisor-zero guard that reproduces
166 /// the Wasm trap, integer div/rem is rejected so it stays on the CPU
167 /// path rather than producing a non-trapping (wrong) result on the GPU.
168 IntDivRem(&'static str),
169
170 /// A control-flow back-edge: a branch whose target is a block at or
171 /// before the branching block in layout order (i.e. a loop).
172 ///
173 /// jit MED fix (finding 4): the wave-2 lowering does not yet model loop
174 /// carried dependencies / convergence on the GPU, so any function
175 /// containing a loop back-edge is rejected. Straight-line and
176 /// forward-only (if/else, switch) control flow remains admissible.
177 BackEdge(&'static str),
178
179 /// An explicit trap or unreachable (`trap`, `trapz`, `trapnz`,
180 /// `resumable_trap`, `debugtrap`; Wasm `unreachable` lowers to `trap`).
181 ///
182 /// jit MED fix (finding 4): PTX has no equivalent of the Wasm trap
183 /// machinery (which unwinds back into the host with a trap code). A
184 /// function that can trap mid-kernel cannot be faithfully offloaded, so
185 /// it is rejected.
186 Trap(&'static str),
187}
188
189impl RejectReason {
190 /// The Cranelift opcode mnemonic that triggered this rejection.
191 ///
192 /// Convenience accessor for log lines / error formatting that want
193 /// the offending opcode name without `match`-ing on the variant.
194 pub fn opcode_mnemonic(&self) -> &'static str {
195 match self {
196 Self::Atomic(m)
197 | Self::StrictFp(m)
198 | Self::TableOp(m)
199 | Self::GcOp(m)
200 | Self::MemoryResize(m)
201 | Self::LargeMemcpy(m)
202 | Self::HostCall(m)
203 | Self::FloatOp(m)
204 | Self::IntDivRem(m)
205 | Self::BackEdge(m)
206 | Self::Trap(m) => m,
207 }
208 }
209}
210
211/// A single rejected instruction with its location in the function.
212///
213/// Returned by [`scan_function`]; the `(inst, block)` pair is enough for
214/// the caller to render a diagnostic with `cranelift_codegen`'s own
215/// pretty-printer (e.g. `func.dfg.display_inst(inst)`).
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct Rejection {
218 /// Cranelift instruction that triggered the rejection.
219 pub inst: Inst,
220 /// Block containing the offending instruction.
221 pub block: Block,
222 /// Reason for rejection (also carries the opcode mnemonic).
223 pub reason: RejectReason,
224}
225
226/// Walk `func` and return every rejection.
227///
228/// Empty `Vec` ⇔ the function is admissible to the Pliron pipeline modulo
229/// per-opcode lowering errors caught later. The detector is intentionally
230/// conservative: when in doubt, reject — it is cheaper to fall back to the
231/// blueprint detector than to half-lower a function and then bail.
232///
233/// The scan is `O(n)` in the number of instructions and allocates only the
234/// returned `Vec`. It does not run the Cranelift verifier and therefore
235/// makes no well-formedness guarantees on `func` beyond what the layout
236/// iterator exposes.
237pub fn scan_function(func: &Function) -> Vec<Rejection> {
238 let mut rejections = Vec::new();
239 let block_order = block_layout_order(func);
240 for block in func.layout.blocks() {
241 let block_idx = block_order.get(&block).copied().unwrap_or(usize::MAX);
242 for inst in func.layout.block_insts(block) {
243 if let Some(reason) = classify_opcode(func.dfg.insts[inst].opcode()) {
244 rejections.push(Rejection {
245 inst,
246 block,
247 reason,
248 });
249 } else if let Some(reason) = back_edge_reason(func, inst, block_idx, &block_order) {
250 // jit MED fix (finding 4): a branch whose target is at or
251 // before this block in layout order is a loop back-edge.
252 rejections.push(Rejection {
253 inst,
254 block,
255 reason,
256 });
257 }
258 }
259 }
260 rejections
261}
262
263/// Convenience wrapper: return the **first** rejection or `None`.
264///
265/// Useful for the call-site shortcut "is this function admissible?" —
266/// avoids the `Vec` allocation when the caller does not need the full
267/// list. Equivalent to `scan_function(func).into_iter().next()` but does
268/// not walk the rest of the function once a rejection is found.
269pub fn check_function(func: &Function) -> Option<Rejection> {
270 let block_order = block_layout_order(func);
271 for block in func.layout.blocks() {
272 let block_idx = block_order.get(&block).copied().unwrap_or(usize::MAX);
273 for inst in func.layout.block_insts(block) {
274 if let Some(reason) = classify_opcode(func.dfg.insts[inst].opcode()) {
275 return Some(Rejection {
276 inst,
277 block,
278 reason,
279 });
280 }
281 if let Some(reason) = back_edge_reason(func, inst, block_idx, &block_order) {
282 return Some(Rejection {
283 inst,
284 block,
285 reason,
286 });
287 }
288 }
289 }
290 None
291}
292
293/// Assign each block its position in layout order. A branch to a block
294/// whose index is `<=` the branching block's index is a back-edge (loop).
295fn block_layout_order(func: &Function) -> HashMap<Block, usize> {
296 func.layout
297 .blocks()
298 .enumerate()
299 .map(|(idx, block)| (block, idx))
300 .collect()
301}
302
303/// If `inst` is a branch with a destination at or before `block_idx` in
304/// layout order, return a [`RejectReason::BackEdge`]. Returns `None` for
305/// non-branch instructions and purely forward branches.
306///
307/// jit MED fix (finding 4): the wave-2 lowering does not model loop-carried
308/// dependencies, so any loop (detected here as a back-edge) is rejected.
309fn back_edge_reason(
310 func: &Function,
311 inst: Inst,
312 block_idx: usize,
313 block_order: &HashMap<Block, usize>,
314) -> Option<RejectReason> {
315 let data = &func.dfg.insts[inst];
316 let opcode = data.opcode();
317 if !opcode.is_branch() {
318 return None;
319 }
320 let mut is_back_edge = false;
321 for block_call in data.branch_destination(&func.dfg.jump_tables) {
322 let target = block_call.block(&func.dfg.value_lists);
323 if let Some(&target_idx) = block_order.get(&target) {
324 if target_idx <= block_idx {
325 is_back_edge = true;
326 }
327 }
328 }
329 if is_back_edge {
330 Some(RejectReason::BackEdge(branch_mnemonic(opcode)))
331 } else {
332 None
333 }
334}
335
336/// Static mnemonic for a branch opcode (diagnostics only).
337fn branch_mnemonic(op: Opcode) -> &'static str {
338 match op {
339 Opcode::Jump => "jump",
340 Opcode::Brif => "brif",
341 Opcode::BrTable => "br_table",
342 _ => "branch",
343 }
344}
345
346/// Map a Cranelift [`Opcode`] to a [`RejectReason`] if it is on the
347/// reject list, otherwise `None`.
348///
349/// Centralised match so the two scanners stay in lock-step. The mnemonic
350/// strings are hard-coded `&'static str` literals (rather than
351/// `opcode_name(op)` / `Display`) to keep [`RejectReason`] `Copy`-able
352/// and avoid the runtime cost of stringification in the hot path; they
353/// are kept identical to Cranelift's own mnemonics so the two are
354/// interchangeable for log output.
355fn classify_opcode(op: Opcode) -> Option<RejectReason> {
356 match op {
357 // Atomics — see RejectReason::Atomic.
358 Opcode::AtomicLoad => Some(RejectReason::Atomic("atomic_load")),
359 Opcode::AtomicStore => Some(RejectReason::Atomic("atomic_store")),
360 Opcode::AtomicRmw => Some(RejectReason::Atomic("atomic_rmw")),
361 Opcode::AtomicCas => Some(RejectReason::Atomic("atomic_cas")),
362
363 // Strict-FP saturating conversions — see RejectReason::StrictFp.
364 // PTX `cvt.f32.s32` / `cvt.f32.u32` do not saturate; the wave-2
365 // lowering cannot honour the Wasm-strict saturation semantics
366 // without an explicit min/max clamp the detector chooses not to
367 // synthesize for v0.4.
368 Opcode::FcvtToSintSat => Some(RejectReason::StrictFp("fcvt_to_sint_sat")),
369 Opcode::FcvtToUintSat => Some(RejectReason::StrictFp("fcvt_to_uint_sat")),
370
371 // Host-call prohibition — see RejectReason::HostCall. This also
372 // catches the libcall-expanded forms of `memory.grow`,
373 // `memory.size`, `memory.copy`, `memory.fill`, `table.get`, and
374 // `table.set` that `cranelift-wasm` emits in pinned Cranelift
375 // 0.111. Wave 3+ will refine this match to peek into `func_ref`
376 // and distinguish device-internal calls from runtime helpers.
377 Opcode::Call => Some(RejectReason::HostCall("call")),
378 Opcode::CallIndirect => Some(RejectReason::HostCall("call_indirect")),
379 Opcode::ReturnCall => Some(RejectReason::HostCall("return_call")),
380 Opcode::ReturnCallIndirect => Some(RejectReason::HostCall("return_call_indirect")),
381
382 // Integer divide / remainder — see RejectReason::IntDivRem. These
383 // trap on divide-by-zero in Wasm but PTX `div`/`rem` do not, so
384 // they are rejected until the trap is emulated (jit MED finding 4).
385 Opcode::Sdiv => Some(RejectReason::IntDivRem("sdiv")),
386 Opcode::Udiv => Some(RejectReason::IntDivRem("udiv")),
387 Opcode::Srem => Some(RejectReason::IntDivRem("srem")),
388 Opcode::Urem => Some(RejectReason::IntDivRem("urem")),
389
390 // Trap / unreachable — see RejectReason::Trap. No PTX equivalent of
391 // the Wasm trap unwind (jit MED finding 4). `unreachable` lowers to
392 // `trap` in Cranelift, so this also covers Wasm `unreachable`.
393 Opcode::Trap => Some(RejectReason::Trap("trap")),
394 Opcode::Trapz => Some(RejectReason::Trap("trapz")),
395 Opcode::Trapnz => Some(RejectReason::Trap("trapnz")),
396 Opcode::Debugtrap => Some(RejectReason::Trap("debugtrap")),
397
398 // All floating-point opcodes — see RejectReason::FloatOp. Rejected
399 // wholesale until bit-exact PTX rounding equivalence is proven by
400 // the differential oracle (jit MED finding 4). The two saturating
401 // conversions keep their more specific `StrictFp` reason above for
402 // diagnostic continuity; everything else float lands here.
403 op if is_float_opcode(op) => Some(RejectReason::FloatOp(float_mnemonic(op))),
404
405 // Everything else is provisionally admissible; the per-family
406 // lowerings may still reject in their own pass.
407 _ => None,
408 }
409}
410
411/// True for any floating-point Cranelift opcode the wave-2 reject-list
412/// refuses (jit MED finding 4). Kept as an explicit allow/deny list rather
413/// than a type-based check because `classify_opcode` only has the opcode in
414/// hand (the scanners deliberately avoid touching value types for speed).
415fn is_float_opcode(op: Opcode) -> bool {
416 matches!(
417 op,
418 Opcode::Fadd
419 | Opcode::Fsub
420 | Opcode::Fmul
421 | Opcode::Fdiv
422 | Opcode::Fma
423 | Opcode::Fneg
424 | Opcode::Fabs
425 | Opcode::Fcopysign
426 | Opcode::Fmin
427 | Opcode::Fmax
428 | Opcode::Sqrt
429 | Opcode::Ceil
430 | Opcode::Floor
431 | Opcode::Trunc
432 | Opcode::Nearest
433 | Opcode::Fcmp
434 | Opcode::Fpromote
435 | Opcode::Fdemote
436 | Opcode::FcvtFromSint
437 | Opcode::FcvtFromUint
438 | Opcode::FcvtToSint
439 | Opcode::FcvtToUint
440 )
441}
442
443/// Static mnemonic for a float opcode that `is_float_opcode` accepts.
444/// Falls back to a generic `"float_op"` label for any opcode not
445/// individually named — the reject decision is what matters, the mnemonic
446/// is only for diagnostics.
447fn float_mnemonic(op: Opcode) -> &'static str {
448 match op {
449 Opcode::Fadd => "fadd",
450 Opcode::Fsub => "fsub",
451 Opcode::Fmul => "fmul",
452 Opcode::Fdiv => "fdiv",
453 Opcode::Fma => "fma",
454 Opcode::Fneg => "fneg",
455 Opcode::Fabs => "fabs",
456 Opcode::Fcopysign => "fcopysign",
457 Opcode::Fmin => "fmin",
458 Opcode::Fmax => "fmax",
459 Opcode::Sqrt => "sqrt",
460 Opcode::Ceil => "ceil",
461 Opcode::Floor => "floor",
462 Opcode::Trunc => "trunc",
463 Opcode::Nearest => "nearest",
464 Opcode::Fcmp => "fcmp",
465 Opcode::Fpromote => "fpromote",
466 Opcode::Fdemote => "fdemote",
467 Opcode::FcvtFromSint => "fcvt_from_sint",
468 Opcode::FcvtFromUint => "fcvt_from_uint",
469 Opcode::FcvtToSint => "fcvt_to_sint",
470 Opcode::FcvtToUint => "fcvt_to_uint",
471 _ => "float_op",
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use cranelift_codegen::ir::immediates::Offset32;
479 use cranelift_codegen::ir::instructions::InstructionData;
480 use cranelift_codegen::ir::{
481 AtomicRmwOp, FuncRef, MemFlags, SigRef, Signature, UserFuncName, Value, ValueList,
482 };
483 use cranelift_codegen::isa::CallConv;
484
485 /// Build a fresh empty function with a single empty entry block.
486 /// The wave-2 detector never inspects values or types, only opcodes,
487 /// so the dummy `Value(0)` / `FuncRef(0)` / `SigRef(0)` placeholders
488 /// the tests use never need to be valid — `scan_function` does not
489 /// dereference them.
490 fn empty_func() -> (Function, Block) {
491 let mut func = Function::with_name_signature(
492 UserFuncName::user(0, 0),
493 Signature::new(CallConv::SystemV),
494 );
495 let block = func.dfg.make_block();
496 func.layout.append_block(block);
497 (func, block)
498 }
499
500 /// Convenience: place `data` at the end of `block` and return the
501 /// resulting `Inst`. Intentionally does NOT call `make_inst_results`
502 /// — the detector ignores result values, and skipping result wiring
503 /// keeps the test fixtures small and verifier-independent.
504 fn append(func: &mut Function, block: Block, data: InstructionData) -> Inst {
505 let inst = func.dfg.make_inst(data);
506 func.layout.append_inst(inst, block);
507 inst
508 }
509
510 /// A throwaway `Value` reference. Never dereferenced by the
511 /// detector, so any well-formed `Value` ID is fine.
512 fn dummy_val() -> Value {
513 Value::from_u32(0)
514 }
515
516 // ---- Positive (admissible) case -----------------------------------
517
518 /// A function with only admissible opcodes (`iadd` + `return`)
519 /// produces no rejections.
520 #[test]
521 fn admissible_iadd_return_yields_empty() {
522 let (mut func, block) = empty_func();
523 append(
524 &mut func,
525 block,
526 InstructionData::Binary {
527 opcode: Opcode::Iadd,
528 args: [dummy_val(), dummy_val()],
529 },
530 );
531 append(
532 &mut func,
533 block,
534 InstructionData::MultiAry {
535 opcode: Opcode::Return,
536 args: ValueList::new(),
537 },
538 );
539
540 assert_eq!(scan_function(&func), vec![]);
541 assert_eq!(check_function(&func), None);
542 }
543
544 // ---- Atomic family -------------------------------------------------
545
546 /// `atomic_load` is rejected with the `Atomic` reason.
547 #[test]
548 fn atomic_load_is_rejected() {
549 let (mut func, block) = empty_func();
550 append(
551 &mut func,
552 block,
553 InstructionData::LoadNoOffset {
554 opcode: Opcode::AtomicLoad,
555 flags: MemFlags::new(),
556 arg: dummy_val(),
557 },
558 );
559
560 let rejections = scan_function(&func);
561 assert_eq!(rejections.len(), 1);
562 assert_eq!(rejections[0].reason, RejectReason::Atomic("atomic_load"));
563 assert_eq!(rejections[0].block, block);
564 }
565
566 /// `atomic_store` is rejected with the `Atomic` reason.
567 #[test]
568 fn atomic_store_is_rejected() {
569 let (mut func, block) = empty_func();
570 append(
571 &mut func,
572 block,
573 InstructionData::StoreNoOffset {
574 opcode: Opcode::AtomicStore,
575 flags: MemFlags::new(),
576 args: [dummy_val(), dummy_val()],
577 },
578 );
579
580 let rejections = scan_function(&func);
581 assert_eq!(rejections.len(), 1);
582 assert_eq!(rejections[0].reason, RejectReason::Atomic("atomic_store"));
583 }
584
585 /// `atomic_rmw` is rejected with the `Atomic` reason.
586 #[test]
587 fn atomic_rmw_is_rejected() {
588 let (mut func, block) = empty_func();
589 append(
590 &mut func,
591 block,
592 InstructionData::AtomicRmw {
593 opcode: Opcode::AtomicRmw,
594 flags: MemFlags::new(),
595 op: AtomicRmwOp::Add,
596 args: [dummy_val(), dummy_val()],
597 },
598 );
599
600 let rejections = scan_function(&func);
601 assert_eq!(rejections.len(), 1);
602 assert_eq!(rejections[0].reason, RejectReason::Atomic("atomic_rmw"));
603 }
604
605 /// `atomic_cas` is rejected with the `Atomic` reason.
606 #[test]
607 fn atomic_cas_is_rejected() {
608 let (mut func, block) = empty_func();
609 append(
610 &mut func,
611 block,
612 InstructionData::AtomicCas {
613 opcode: Opcode::AtomicCas,
614 flags: MemFlags::new(),
615 args: [dummy_val(), dummy_val(), dummy_val()],
616 },
617 );
618
619 let rejections = scan_function(&func);
620 assert_eq!(rejections.len(), 1);
621 assert_eq!(rejections[0].reason, RejectReason::Atomic("atomic_cas"));
622 }
623
624 // ---- Strict-FP family ----------------------------------------------
625
626 /// `fcvt_to_sint_sat` is rejected with the `StrictFp` reason.
627 #[test]
628 fn fcvt_to_sint_sat_is_rejected() {
629 let (mut func, block) = empty_func();
630 append(
631 &mut func,
632 block,
633 InstructionData::Unary {
634 opcode: Opcode::FcvtToSintSat,
635 arg: dummy_val(),
636 },
637 );
638
639 let rejections = scan_function(&func);
640 assert_eq!(rejections.len(), 1);
641 assert_eq!(
642 rejections[0].reason,
643 RejectReason::StrictFp("fcvt_to_sint_sat")
644 );
645 }
646
647 /// `fcvt_to_uint_sat` is also rejected (sibling saturating
648 /// conversion). Covers the second variant of the `StrictFp` family
649 /// the detector recognises.
650 #[test]
651 fn fcvt_to_uint_sat_is_rejected() {
652 let (mut func, block) = empty_func();
653 append(
654 &mut func,
655 block,
656 InstructionData::Unary {
657 opcode: Opcode::FcvtToUintSat,
658 arg: dummy_val(),
659 },
660 );
661
662 let rejections = scan_function(&func);
663 assert_eq!(rejections.len(), 1);
664 assert_eq!(
665 rejections[0].reason,
666 RejectReason::StrictFp("fcvt_to_uint_sat")
667 );
668 }
669
670 // ---- Host-call family ----------------------------------------------
671
672 /// `call` is rejected with the `HostCall` reason. The dummy
673 /// `FuncRef(0)` is never resolved by the detector.
674 #[test]
675 fn call_is_rejected() {
676 let (mut func, block) = empty_func();
677 append(
678 &mut func,
679 block,
680 InstructionData::Call {
681 opcode: Opcode::Call,
682 func_ref: FuncRef::from_u32(0),
683 args: ValueList::new(),
684 },
685 );
686
687 let rejections = scan_function(&func);
688 assert_eq!(rejections.len(), 1);
689 assert_eq!(rejections[0].reason, RejectReason::HostCall("call"));
690 }
691
692 /// `call_indirect` is rejected with the `HostCall` reason.
693 #[test]
694 fn call_indirect_is_rejected() {
695 let (mut func, block) = empty_func();
696 append(
697 &mut func,
698 block,
699 InstructionData::CallIndirect {
700 opcode: Opcode::CallIndirect,
701 sig_ref: SigRef::from_u32(0),
702 args: ValueList::new(),
703 },
704 );
705
706 let rejections = scan_function(&func);
707 assert_eq!(rejections.len(), 1);
708 assert_eq!(
709 rejections[0].reason,
710 RejectReason::HostCall("call_indirect")
711 );
712 }
713
714 // ---- TableOp / GcOp / MemoryResize / LargeMemcpy -------------------
715 //
716 // These four categories are pre-declared in `RejectReason` for forward
717 // compatibility but are not wired to a specific Cranelift opcode in
718 // 0.111.9 (see module docs — the wasm-to-clif translator expands them
719 // into `call` libcalls before the detector sees them, so they are
720 // caught indirectly via `HostCall`). The unit tests below exercise
721 // the *variant* surface — constructing and inspecting each reason —
722 // so a future wiring patch only needs to add a `match` arm in
723 // `classify_opcode`, not redesign the public enum.
724
725 /// The `TableOp` variant round-trips through `opcode_mnemonic`.
726 /// Pre-declared variant; no opcode wired in 0.111.
727 #[test]
728 fn table_op_variant_is_constructible() {
729 let r = RejectReason::TableOp("table_get");
730 assert_eq!(r.opcode_mnemonic(), "table_get");
731 }
732
733 /// The `GcOp` variant round-trips through `opcode_mnemonic`.
734 /// Pre-declared variant; no opcode wired in 0.111.
735 #[test]
736 fn gc_op_variant_is_constructible() {
737 let r = RejectReason::GcOp("ref_func");
738 assert_eq!(r.opcode_mnemonic(), "ref_func");
739 }
740
741 /// The `MemoryResize` variant round-trips through `opcode_mnemonic`.
742 /// Pre-declared variant; no opcode wired in 0.111.
743 #[test]
744 fn memory_resize_variant_is_constructible() {
745 let r = RejectReason::MemoryResize("memory_grow");
746 assert_eq!(r.opcode_mnemonic(), "memory_grow");
747 }
748
749 /// The `LargeMemcpy` variant round-trips through `opcode_mnemonic`.
750 /// Pre-declared variant; no opcode wired in 0.111.
751 #[test]
752 fn large_memcpy_variant_is_constructible() {
753 let r = RejectReason::LargeMemcpy("memory_copy");
754 assert_eq!(r.opcode_mnemonic(), "memory_copy");
755 }
756
757 // ---- Multi-rejection / ordering ------------------------------------
758
759 /// A function with two rejected instructions returns both, in
760 /// layout order. Locks in the contract that the detector is a
761 /// *list* not a "first failure" — wave 3+ diagnostics will rely on
762 /// the full list to surface every issue at once.
763 #[test]
764 fn multiple_rejections_returned_in_layout_order() {
765 let (mut func, block) = empty_func();
766 append(
767 &mut func,
768 block,
769 InstructionData::LoadNoOffset {
770 opcode: Opcode::AtomicLoad,
771 flags: MemFlags::new(),
772 arg: dummy_val(),
773 },
774 );
775 append(
776 &mut func,
777 block,
778 InstructionData::Call {
779 opcode: Opcode::Call,
780 func_ref: FuncRef::from_u32(0),
781 args: ValueList::new(),
782 },
783 );
784
785 let rejections = scan_function(&func);
786 assert_eq!(rejections.len(), 2);
787 assert_eq!(rejections[0].reason, RejectReason::Atomic("atomic_load"));
788 assert_eq!(rejections[1].reason, RejectReason::HostCall("call"));
789 }
790
791 /// `check_function` returns the first rejection encountered (early
792 /// exit), not the full list.
793 #[test]
794 fn check_function_returns_first_rejection() {
795 let (mut func, block) = empty_func();
796 append(
797 &mut func,
798 block,
799 InstructionData::Call {
800 opcode: Opcode::Call,
801 func_ref: FuncRef::from_u32(0),
802 args: ValueList::new(),
803 },
804 );
805 append(
806 &mut func,
807 block,
808 InstructionData::LoadNoOffset {
809 opcode: Opcode::AtomicLoad,
810 flags: MemFlags::new(),
811 arg: dummy_val(),
812 },
813 );
814
815 let first = check_function(&func).expect("function has rejections");
816 assert_eq!(first.reason, RejectReason::HostCall("call"));
817 }
818
819 /// `Offset32` is included in the `cranelift_codegen::ir::immediates`
820 /// import set above so the test module compiles even when no test
821 /// directly references it; this assertion keeps the import live and
822 /// documents that the detector intentionally ignores instruction
823 /// offsets (only opcode identity matters).
824 #[test]
825 fn offset_field_is_irrelevant_to_detection() {
826 let _ = Offset32::new(0);
827 }
828
829 // ---- jit MED fix (finding 4): float / div-rem / trap / back-edge ----
830
831 /// All floating-point arithmetic is rejected with `FloatOp` until
832 /// bit-exact PTX rounding is proven.
833 #[test]
834 fn float_arith_is_rejected() {
835 for (opcode, mnemonic) in [
836 (Opcode::Fadd, "fadd"),
837 (Opcode::Fmul, "fmul"),
838 (Opcode::Fdiv, "fdiv"),
839 ] {
840 let (mut func, block) = empty_func();
841 append(
842 &mut func,
843 block,
844 InstructionData::Binary {
845 opcode,
846 args: [dummy_val(), dummy_val()],
847 },
848 );
849 let first = check_function(&func).expect("float op must be rejected");
850 assert_eq!(first.reason, RejectReason::FloatOp(mnemonic));
851 }
852 }
853
854 /// Float unary ops (`sqrt`) are rejected too.
855 #[test]
856 fn float_sqrt_is_rejected() {
857 let (mut func, block) = empty_func();
858 append(
859 &mut func,
860 block,
861 InstructionData::Unary {
862 opcode: Opcode::Sqrt,
863 arg: dummy_val(),
864 },
865 );
866 let first = check_function(&func).expect("sqrt must be rejected");
867 assert_eq!(first.reason, RejectReason::FloatOp("sqrt"));
868 }
869
870 /// Integer divide / remainder is rejected until divide-by-zero trap
871 /// emulation lands.
872 #[test]
873 fn int_div_rem_is_rejected() {
874 for (opcode, mnemonic) in [
875 (Opcode::Sdiv, "sdiv"),
876 (Opcode::Udiv, "udiv"),
877 (Opcode::Srem, "srem"),
878 (Opcode::Urem, "urem"),
879 ] {
880 let (mut func, block) = empty_func();
881 append(
882 &mut func,
883 block,
884 InstructionData::Binary {
885 opcode,
886 args: [dummy_val(), dummy_val()],
887 },
888 );
889 let first = check_function(&func).expect("int div/rem must be rejected");
890 assert_eq!(first.reason, RejectReason::IntDivRem(mnemonic));
891 }
892 }
893
894 /// `iadd` (a non-trapping integer op) stays admissible — the div/rem
895 /// reject must not over-reach to all integer arithmetic.
896 #[test]
897 fn plain_iadd_still_admissible() {
898 let (mut func, block) = empty_func();
899 append(
900 &mut func,
901 block,
902 InstructionData::Binary {
903 opcode: Opcode::Iadd,
904 args: [dummy_val(), dummy_val()],
905 },
906 );
907 assert_eq!(check_function(&func), None);
908 }
909
910 /// `trap` and friends are rejected.
911 #[test]
912 fn trap_is_rejected() {
913 let (mut func, block) = empty_func();
914 append(
915 &mut func,
916 block,
917 InstructionData::Trap {
918 opcode: Opcode::Trap,
919 code: cranelift_codegen::ir::TrapCode::User(1),
920 },
921 );
922 let first = check_function(&func).expect("trap must be rejected");
923 assert_eq!(first.reason, RejectReason::Trap("trap"));
924 }
925
926 /// A loop back-edge (a `jump` whose target is the entry block, which is
927 /// at or before the branching block in layout order) is rejected; a
928 /// forward jump is NOT.
929 #[test]
930 fn loop_back_edge_is_rejected_forward_jump_is_not() {
931 use cranelift_codegen::ir::BlockCall;
932
933 // Build entry -> body, with body jumping BACK to entry (a loop).
934 let mut func = Function::with_name_signature(
935 UserFuncName::user(0, 0),
936 Signature::new(CallConv::SystemV),
937 );
938 let entry = func.dfg.make_block();
939 let body = func.dfg.make_block();
940 func.layout.append_block(entry);
941 func.layout.append_block(body);
942
943 // entry: jump body (forward — admissible)
944 let fwd_call = BlockCall::new(body, &[], &mut func.dfg.value_lists);
945 let fwd = func.dfg.make_inst(InstructionData::Jump {
946 opcode: Opcode::Jump,
947 destination: fwd_call,
948 });
949 func.layout.append_inst(fwd, entry);
950
951 // body: jump entry (back-edge — rejected)
952 let back_call = BlockCall::new(entry, &[], &mut func.dfg.value_lists);
953 let back = func.dfg.make_inst(InstructionData::Jump {
954 opcode: Opcode::Jump,
955 destination: back_call,
956 });
957 func.layout.append_inst(back, body);
958
959 let rejections = scan_function(&func);
960 assert_eq!(
961 rejections.len(),
962 1,
963 "exactly the back-edge is rejected, not the forward jump"
964 );
965 assert!(matches!(rejections[0].reason, RejectReason::BackEdge(_)));
966 assert_eq!(rejections[0].block, body);
967 }
968
969 /// A self-loop (`jump` to the block's own block) is a back-edge.
970 #[test]
971 fn self_loop_is_back_edge() {
972 use cranelift_codegen::ir::BlockCall;
973 let (mut func, block) = empty_func();
974 let call = BlockCall::new(block, &[], &mut func.dfg.value_lists);
975 let jmp = func.dfg.make_inst(InstructionData::Jump {
976 opcode: Opcode::Jump,
977 destination: call,
978 });
979 func.layout.append_inst(jmp, block);
980 let first = check_function(&func).expect("self-loop must be rejected");
981 assert!(matches!(first.reason, RejectReason::BackEdge(_)));
982 }
983}