synth_core/wasm_stack_check.rs
1//! Pre-flight wasm value-stack underflow detector.
2//!
3//! Real wasm input is validated by the decoder (wasmparser). This module is
4//! a safety net for *direct callers* of the lowering pipeline that feed in
5//! raw `Vec<WasmOp>` without going through the validator — most notably the
6//! fuzz harnesses, which intentionally generate malformed sequences to
7//! prove the contract that lowering returns `Err`, not panics.
8//!
9//! The check is best-effort and, above all, *sound* — it never rejects valid
10//! wasm. Stack effects that depend on data we don't have here (block-result
11//! arities, callee signatures) are handled conservatively:
12//!
13//! * `Call` and unmodeled ops (SIMD, etc.) bail out with `Ok(())` — their
14//! effect is signature/type dependent.
15//! * The stack-polymorphic terminators `unreachable`/`return`/`br`/`br_table`
16//! also bail with `Ok(())`: everything after them (up to the enclosing
17//! `end`) is unreachable and, per the wasm spec, type-checks against an
18//! infinite-depth polymorphic stack, so depth-only reasoning would produce
19//! *false* underflows there (issue #329).
20//! * `Block`/`Loop`/`If`/`Else`/`End` are modeled as stack-neutral. In
21//! reachable code the depth counter can then only ever *over*-count (it
22//! never pops the `if` condition and never resets at `else`/`end`), so it
23//! cannot invent an underflow — it just catches fewer of them past a block.
24//!
25//! The net effect: the check reliably rejects the control-flow-free underflow
26//! shapes (the fuzz-harness bug class below) and never false-rejects a
27//! `wasm-tools`-valid module.
28//!
29//! The bug this was written for ([PR #113 fuzz harness wasm_ops_lower_or_error,
30//! input `[I32DivS]` with empty initial stack]) sits squarely inside the
31//! modeled subset, which is the common case.
32//!
33//! ## Scope
34//!
35//! The validator does *not* enforce wasm type checking — it only tracks
36//! stack *depth*. So `i32.const ; i64.add` will pass even though it's
37//! type-invalid. Type errors fall to the lowering pipeline, which now
38//! raises them as `Err` (per PR #117 — the same audit pass).
39//!
40//! ## Why not just call wasmparser?
41//!
42//! Two reasons:
43//! * The lowering pipeline accepts `Vec<WasmOp>` (its own enum), not raw
44//! wasm bytes. Threading wasmparser back would require a re-encoder.
45//! * The harnesses *want* to feed malformed input. We want a cheap local
46//! check that returns Err rather than panics, not full re-validation.
47//!
48//! See PR #117 for the original fuzz crash that motivated this module.
49//!
50//! Note: `Select` is modeled as `pop 3, push 1` — wasm's `select` consumes
51//! two values and a condition. `MemoryGrow` pops a page count and pushes
52//! the previous size (or -1). `MemorySize` is a pure push.
53
54use crate::Error;
55use crate::wasm_op::WasmOp;
56
57/// Pre-flight check: returns `Err(Error::validation(...))` if any modeled
58/// op would underflow the wasm value stack. If the sequence contains
59/// control-flow ops we don't model, returns `Ok(())` (bails conservatively).
60pub fn check_no_underflow(wasm_ops: &[WasmOp]) -> crate::Result<()> {
61 let mut depth: i64 = 0;
62 for (idx, op) in wasm_ops.iter().enumerate() {
63 match stack_effect_or_bail(op) {
64 StackEffect::Modeled { pops, pushes } => {
65 if depth < pops as i64 {
66 return Err(Error::validation(format!(
67 "wasm value-stack underflow at op {idx} ({op:?}): \
68 would pop {pops} from depth {depth}"
69 )));
70 }
71 depth -= pops as i64;
72 depth += pushes as i64;
73 }
74 StackEffect::Bail => return Ok(()),
75 }
76 }
77 Ok(())
78}
79
80/// #587: a conservative UPPER BOUND on the wasm value-stack depth this op
81/// sequence can reach. Used by the ARM backend's `pool-grow` exhaustion-
82/// recovery rung to size the i64 spill-slot pool: the number of values
83/// *simultaneously* spilled by the direct selector can never exceed the
84/// number of values simultaneously live on the operand stack, so a pool of
85/// `max_depth_bound` slots (plus the resolver/result-parking transients the
86/// caller adds) cannot exhaust through the deepest-value spill loop.
87///
88/// Over-approximation rules (never under-counts in reachable code):
89/// * Modeled ops apply their exact pops/pushes; a would-be underflow clamps
90/// to 0 (malformed input is someone else's Err, not a panic here).
91/// * Unmodeled/`Bail` ops (`call`, terminators, SIMD, …) are treated as net
92/// `+1` — every wasm op pushes at most one value net, so this only ever
93/// over-counts (a call pops its args; a terminator pushes nothing).
94/// * `Block`/`Loop`/`If`/`Else`/`End` are stack-neutral in the effects table,
95/// which over-counts (`if` really pops its condition) — same direction.
96pub fn max_depth_bound(wasm_ops: &[WasmOp]) -> u32 {
97 let mut depth: i64 = 0;
98 let mut max: i64 = 0;
99 for op in wasm_ops {
100 match stack_effect_or_bail(op) {
101 StackEffect::Modeled { pops, pushes } => {
102 depth = (depth - pops as i64).max(0) + pushes as i64;
103 }
104 StackEffect::Bail => depth += 1,
105 }
106 max = max.max(depth);
107 }
108 u32::try_from(max).unwrap_or(u32::MAX)
109}
110
111enum StackEffect {
112 Modeled { pops: u32, pushes: u32 },
113 Bail,
114}
115
116fn modeled(pops: u32, pushes: u32) -> StackEffect {
117 StackEffect::Modeled { pops, pushes }
118}
119
120#[allow(clippy::too_many_lines)]
121fn stack_effect_or_bail(op: &WasmOp) -> StackEffect {
122 use WasmOp::*;
123 match op {
124 // ---- pushes (constants, reads) -----------------------------------
125 I32Const(_) | I64Const(_) | F32Const(_) | F64Const(_) | V128Const(_) | LocalGet(_)
126 | GlobalGet(_) | MemorySize(_) => modeled(0, 1),
127
128 // ---- i32 binary (pop 2, push 1) ----------------------------------
129 I32Add | I32Sub | I32Mul | I32DivS | I32DivU | I32RemS | I32RemU | I32And | I32Or
130 | I32Xor | I32Shl | I32ShrS | I32ShrU | I32Rotl | I32Rotr | I32Eq | I32Ne | I32LtS
131 | I32LtU | I32LeS | I32LeU | I32GtS | I32GtU | I32GeS | I32GeU => modeled(2, 1),
132
133 // ---- i32 unary (pop 1, push 1) -----------------------------------
134 I32Clz | I32Ctz | I32Popcnt | I32Eqz | I32Extend8S | I32Extend16S | I32WrapI64 => {
135 modeled(1, 1)
136 }
137
138 // ---- i64 binary (pop 2, push 1) ----------------------------------
139 I64Add | I64Sub | I64Mul | I64DivS | I64DivU | I64RemS | I64RemU | I64And | I64Or
140 | I64Xor | I64Shl | I64ShrS | I64ShrU | I64Rotl | I64Rotr | I64Eq | I64Ne | I64LtS
141 | I64LtU | I64LeS | I64LeU | I64GtS | I64GtU | I64GeS | I64GeU => modeled(2, 1),
142
143 // ---- i64 unary (pop 1, push 1) -----------------------------------
144 I64Clz | I64Ctz | I64Popcnt | I64Eqz | I64Extend8S | I64Extend16S | I64Extend32S
145 | I64ExtendI32S | I64ExtendI32U => modeled(1, 1),
146
147 // ---- f32 binary --------------------------------------------------
148 F32Add | F32Sub | F32Mul | F32Div | F32Eq | F32Ne | F32Lt | F32Le | F32Gt | F32Ge
149 | F32Min | F32Max | F32Copysign => modeled(2, 1),
150
151 // ---- f32 unary ---------------------------------------------------
152 F32Abs | F32Neg | F32Ceil | F32Floor | F32Trunc | F32Nearest | F32Sqrt => modeled(1, 1),
153
154 // ---- f64 binary --------------------------------------------------
155 F64Add | F64Sub | F64Mul | F64Div | F64Eq | F64Ne | F64Lt | F64Le | F64Gt | F64Ge
156 | F64Min | F64Max | F64Copysign => modeled(2, 1),
157
158 // ---- f64 unary ---------------------------------------------------
159 F64Abs | F64Neg | F64Ceil | F64Floor | F64Trunc | F64Nearest | F64Sqrt => modeled(1, 1),
160
161 // ---- f32 ↔ f64 / int conversions (pop 1, push 1) -----------------
162 F32ConvertI32S | F32ConvertI32U | F32ConvertI64S | F32ConvertI64U | F32DemoteF64
163 | F32ReinterpretI32 | I32ReinterpretF32 | I32TruncF32S | I32TruncF32U | F64ConvertI32S
164 | F64ConvertI32U | F64ConvertI64S | F64ConvertI64U | F64PromoteF32 | F64ReinterpretI64
165 | I64ReinterpretF64 | I64TruncF64S | I64TruncF64U | I32TruncF64S | I32TruncF64U
166 | I64TruncF32S | I64TruncF32U | I32TruncSatF32S | I32TruncSatF32U | I32TruncSatF64S
167 | I32TruncSatF64U | I64TruncSatF32S | I64TruncSatF32U | I64TruncSatF64S
168 | I64TruncSatF64U => modeled(1, 1),
169
170 // ---- pop-only ----------------------------------------------------
171 LocalSet(_) | GlobalSet(_) | Drop => modeled(1, 0),
172
173 // ---- pop-modify-push (peek-write) --------------------------------
174 LocalTee(_) => modeled(1, 1),
175
176 // ---- memory ------------------------------------------------------
177 // load: pops address, pushes value
178 I32Load { .. }
179 | I32Load8S { .. }
180 | I32Load8U { .. }
181 | I32Load16S { .. }
182 | I32Load16U { .. }
183 | I64Load { .. }
184 | I64Load8S { .. }
185 | I64Load8U { .. }
186 | I64Load16S { .. }
187 | I64Load16U { .. }
188 | I64Load32S { .. }
189 | I64Load32U { .. }
190 | F32Load { .. }
191 | F64Load { .. } => modeled(1, 1),
192 // store: pops value, pops address
193 I32Store { .. }
194 | I32Store8 { .. }
195 | I32Store16 { .. }
196 | I64Store { .. }
197 | I64Store8 { .. }
198 | I64Store16 { .. }
199 | I64Store32 { .. }
200 | F32Store { .. }
201 | F64Store { .. } => modeled(2, 0),
202 // memory.grow: pops page count, pushes previous size or -1
203 MemoryGrow(_) => modeled(1, 1),
204
205 // ---- bulk memory (#374) -----------------------------------------
206 // memory.copy(dst, src, len) and memory.fill(dst, val, len) each pop
207 // three i32 operands and push nothing.
208 MemoryCopy | MemoryFill => modeled(3, 0),
209
210 // ---- multi-memory (#406) ----------------------------------------
211 // A wrapped load/store has exactly its inner op's stack effect — the
212 // memory index changes the BASE it addresses, not the operand shape.
213 MultiMemory { op, .. } => stack_effect_or_bail(op),
214
215 // ---- select / nop -----------------------------------------------
216 // select: pops two values and a condition (i32), pushes one value
217 Select => modeled(3, 1),
218 Nop => modeled(0, 0),
219
220 // ---- stack-polymorphic terminators (#329) ------------------------
221 // `unreachable`, `return`, `br`, and `br_table` unconditionally
222 // transfer control, so every op *after* one of them (up to the
223 // enclosing `end`) is unreachable and, per the wasm spec, type-checks
224 // against an infinite-depth *polymorphic* stack. A
225 // `drop`/`select`/`local.set`/binary op in that dead region is
226 // perfectly valid wasm even at depth 0 — but our finite depth counter
227 // keeps decrementing and reports a *false* underflow (issue #329).
228 //
229 // (Note: falcon's original `func_30`/`func_39` underflows were a
230 // *different* root cause — the old #369 silent float-op decoder drop,
231 // which dropped pushes and starved the abstract stack; that was fixed
232 // by #369's loud-skip. This arm closes the remaining, latent
233 // dead-code-after-terminator false-positive in the same model.)
234 //
235 // Note the model can only ever *over*-count in reachable code (it
236 // never pops the `if` condition, never resets at `else`/`end`), so a
237 // false underflow is impossible there. Dead code after a polymorphic
238 // terminator is the sole false-reject class — and without block-result
239 // arities we cannot tell where reachable code resumes after the
240 // matching `end`. So we BAIL to `Ok(())` at the terminator: this keeps
241 // the check SOUND (it can only miss a genuine underflow, never invent
242 // one) and matches the module's documented "accept when unsure" intent.
243 //
244 // This does NOT reintroduce the PR #117 fuzz crashes. Those were
245 // panics deep in `wasm_to_ir`/`ir_to_arm` on shapes like
246 // `[Unreachable, I32GeS]`; the panic sites were since converted to
247 // typed `Err` (issue #93 / PR #101 `get_arm_reg`, issue #121
248 // `slot_stack`, and the `Unreachable`/`Return` handlers in
249 // `wasm_to_ir`). The fuzz contract is *no panic* — `Ok` or `Err` both
250 // pass — and those downstream changes, not this pre-flight, guarantee
251 // it. See the `*_does_not_panic_*` regression tests in synth-synthesis.
252 Unreachable | Return | Br(_) | BrTable { .. } => StackEffect::Bail,
253 // BrIf pops the condition (i32) but does NOT terminate — the
254 // fall-through path keeps executing reachable code. After it the stack
255 // lost the condition, so a genuine depth-0 `br_if` still underflows
256 // (kept as a real-underflow anchor).
257 BrIf(_) => modeled(1, 0),
258 // Block / Loop / If / Else / End — control region delimiters. Their
259 // stack effect depends on block type, which we don't have. Treat as
260 // stack-neutral; if a real underflow lurks past one of these, we
261 // accept it (matches the pre-flight's "best-effort safety net" intent).
262 Block | Loop | If | Else | End => modeled(0, 0),
263 // Call — pops N args, pushes M results. Without the callee's
264 // signature we can't compute this. Yield to upstream validation.
265 Call(_) => StackEffect::Bail,
266
267 // ---- SIMD lane ops, etc. — bail ---------------------------------
268 // The selector doesn't fully support these yet; their stack effects
269 // are well-defined but we don't enumerate them here. Bail.
270 _ => StackEffect::Bail,
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn binary_op_at_empty_stack_is_underflow() {
280 // This is the exact crash input from PR #113's fuzz harness:
281 // FuzzInput { num_params: 1, ops: [I32DivS] }
282 let err = check_no_underflow(&[WasmOp::I32DivS]).unwrap_err();
283 assert!(matches!(err, Error::ValidationError(_)), "got: {err:?}");
284 let msg = format!("{err}");
285 assert!(msg.contains("underflow"));
286 assert!(msg.contains("I32DivS"));
287 }
288
289 #[test]
290 fn well_formed_add_passes() {
291 let ops = vec![WasmOp::I32Const(1), WasmOp::I32Const(2), WasmOp::I32Add];
292 assert!(check_no_underflow(&ops).is_ok());
293 }
294
295 #[test]
296 fn unary_op_at_empty_stack_is_underflow() {
297 let err = check_no_underflow(&[WasmOp::I32Eqz]).unwrap_err();
298 assert!(matches!(err, Error::ValidationError(_)));
299 }
300
301 #[test]
302 fn drop_at_empty_stack_is_underflow() {
303 let err = check_no_underflow(&[WasmOp::Drop]).unwrap_err();
304 assert!(matches!(err, Error::ValidationError(_)));
305 }
306
307 #[test]
308 fn bulk_memory_pops_three_374() {
309 // memory.copy / memory.fill pop 3, push 0: three pushed operands then the
310 // op must balance to depth 0.
311 for op in [WasmOp::MemoryCopy, WasmOp::MemoryFill] {
312 let ok = vec![
313 WasmOp::I32Const(0),
314 WasmOp::I32Const(0),
315 WasmOp::I32Const(0),
316 op.clone(),
317 ];
318 assert!(check_no_underflow(&ok).is_ok(), "{op:?} with 3 operands");
319 // only two operands -> underflow
320 let bad = vec![WasmOp::I32Const(0), WasmOp::I32Const(0), op.clone()];
321 assert!(
322 matches!(
323 check_no_underflow(&bad).unwrap_err(),
324 Error::ValidationError(_)
325 ),
326 "{op:?} with 2 operands must underflow"
327 );
328 }
329 }
330
331 #[test]
332 fn store_at_empty_stack_is_underflow() {
333 let err = check_no_underflow(&[WasmOp::I32Store {
334 offset: 0,
335 align: 2,
336 }])
337 .unwrap_err();
338 assert!(matches!(err, Error::ValidationError(_)));
339 }
340
341 #[test]
342 fn select_needs_three_operands() {
343 // select with only 2 operands underflows.
344 let ops = vec![WasmOp::I32Const(1), WasmOp::I32Const(2), WasmOp::Select];
345 let err = check_no_underflow(&ops).unwrap_err();
346 assert!(matches!(err, Error::ValidationError(_)));
347 }
348
349 #[test]
350 fn select_with_three_operands_passes() {
351 let ops = vec![
352 WasmOp::I32Const(1),
353 WasmOp::I32Const(2),
354 WasmOp::I32Const(0),
355 WasmOp::Select,
356 ];
357 assert!(check_no_underflow(&ops).is_ok());
358 }
359
360 #[test]
361 fn call_bails_conservatively() {
362 // Call(_) has a callee-signature-dependent stack effect we can't
363 // compute here, so we bail (accept). Upstream wasm validation
364 // catches real signature mismatches.
365 let ops = vec![WasmOp::Call(0), WasmOp::I32Add];
366 assert!(check_no_underflow(&ops).is_ok());
367 }
368
369 #[test]
370 fn return_then_binary_op_is_accepted_dead_code_329() {
371 // #329: after `return`, the rest of the block is unreachable and
372 // type-checks against a polymorphic (infinite-depth) stack in wasm, so
373 // `[Return, I64Eqz]` is VALID wasm — the pre-flight must not invent an
374 // underflow. (It previously did, modeling `Return` as stack-neutral.)
375 // The downstream `wasm_to_ir` panic-safety this used to stand in for is
376 // now guaranteed by the `slot_stack`/`get_arm_reg` Err conversions —
377 // see the synth-synthesis `*_does_not_panic_*` regression tests.
378 let ops = vec![WasmOp::Return, WasmOp::I64Eqz];
379 assert!(check_no_underflow(&ops).is_ok());
380 }
381
382 #[test]
383 fn br_then_binary_op_is_accepted_dead_code_329() {
384 // Mirror of the Return case for unconditional branch: code after `br`
385 // is unreachable/polymorphic, hence accepted.
386 let ops = vec![WasmOp::Br(0), WasmOp::I32Add];
387 assert!(check_no_underflow(&ops).is_ok());
388 }
389
390 #[test]
391 fn br_table_then_pop_is_accepted_dead_code_329() {
392 // br_table is also a stack-polymorphic terminator.
393 let ops = vec![
394 WasmOp::BrTable {
395 targets: vec![0],
396 default: 0,
397 },
398 WasmOp::Select,
399 ];
400 assert!(check_no_underflow(&ops).is_ok());
401 }
402
403 #[test]
404 fn br_if_pops_condition() {
405 // BrIf pops one (the i32 condition). At depth 0, the BrIf itself
406 // underflows.
407 let ops = vec![WasmOp::BrIf(0)];
408 let err = check_no_underflow(&ops).unwrap_err();
409 assert!(matches!(err, Error::ValidationError(_)));
410 }
411
412 #[test]
413 fn br_if_with_condition_then_op_is_ok() {
414 // BrIf pops 1 (the condition), then I32Const pushes 1, then
415 // I32Eqz pops 1 / pushes 1 — no underflow.
416 let ops = vec![
417 WasmOp::I32Const(1),
418 WasmOp::BrIf(0),
419 WasmOp::I32Const(0),
420 WasmOp::I32Eqz,
421 ];
422 assert!(check_no_underflow(&ops).is_ok());
423 }
424
425 #[test]
426 fn unreachable_then_binary_op_is_accepted_dead_code_329() {
427 // `[Unreachable, I32GeS]` is VALID wasm: after `unreachable` the stack
428 // is polymorphic, so i32.ge_s type-checks. The pre-flight must accept
429 // it (it previously reported a false underflow). The `wasm_to_ir`
430 // no-panic guarantee this used to proxy for now lives downstream.
431 let ops = vec![WasmOp::Unreachable, WasmOp::I32GeS];
432 assert!(check_no_underflow(&ops).is_ok());
433 }
434
435 #[test]
436 fn unreachable_then_consts_then_binary_op_is_ok() {
437 // Also valid — and accepted whether or not the consts re-push (we bail
438 // at the `unreachable`).
439 let ops = vec![
440 WasmOp::Unreachable,
441 WasmOp::I32Const(1),
442 WasmOp::I32Const(2),
443 WasmOp::I32GeS,
444 ];
445 assert!(check_no_underflow(&ops).is_ok());
446 }
447
448 #[test]
449 fn unreachable_then_drop_is_accepted_329() {
450 // Minimal #329 repro shape: `(unreachable) (drop)` — wasm-tools valid,
451 // previously rejected with "would pop 1 from depth 0".
452 let ops = vec![WasmOp::Unreachable, WasmOp::Drop];
453 assert!(check_no_underflow(&ops).is_ok());
454 }
455
456 #[test]
457 fn return_then_select_is_accepted_329() {
458 // The #329 `func_39` Select shape: a select in dead code after a
459 // terminator. Previously "would pop 3 from depth N".
460 let ops = vec![WasmOp::I32Const(0), WasmOp::Return, WasmOp::Select];
461 assert!(check_no_underflow(&ops).is_ok());
462 }
463
464 #[test]
465 fn return_then_local_set_is_accepted_329() {
466 // The #329 `func_30` LocalSet shape: a local.set in dead code.
467 // Previously "would pop 1 from depth 0".
468 let ops = vec![WasmOp::Return, WasmOp::LocalSet(0)];
469 assert!(check_no_underflow(&ops).is_ok());
470 }
471
472 #[test]
473 fn reachable_select_with_block_result_operand_is_ok_329() {
474 // A reachable select whose operands include a block result stays
475 // accepted — the depth counter over-counts across the block markers,
476 // so it never false-rejects. (Sanity that we didn't over-loosen away
477 // from reachable control flow.)
478 let ops = vec![
479 WasmOp::Block,
480 WasmOp::I32Const(5),
481 WasmOp::End,
482 WasmOp::LocalGet(0),
483 WasmOp::LocalGet(1),
484 WasmOp::Select,
485 ];
486 assert!(check_no_underflow(&ops).is_ok());
487 }
488
489 #[test]
490 fn reachable_binary_op_underflow_still_caught_after_block() {
491 // Bounded-loosening anchor: a genuine underflow that does NOT sit in a
492 // dead region is still caught. `Block` is stack-neutral, then I32Add at
493 // depth 0 underflows.
494 let ops = vec![WasmOp::Block, WasmOp::I32Add];
495 let err = check_no_underflow(&ops).unwrap_err();
496 assert!(matches!(err, Error::ValidationError(_)));
497 }
498
499 #[test]
500 fn const_then_unary_then_binary() {
501 // const → eqz → const → const → add — last add needs 2, has 3.
502 let ops = vec![
503 WasmOp::I32Const(0),
504 WasmOp::I32Eqz,
505 WasmOp::I32Const(1),
506 WasmOp::I32Const(2),
507 WasmOp::I32Add,
508 ];
509 assert!(check_no_underflow(&ops).is_ok());
510 }
511
512 #[test]
513 fn empty_input_is_ok() {
514 assert!(check_no_underflow(&[]).is_ok());
515 }
516
517 #[test]
518 fn max_depth_bound_exact_on_modeled_ops() {
519 // #587: 3 consts (depth 3) folded to 1 — the bound is the peak, 3.
520 let ops = vec![
521 WasmOp::I32Const(1),
522 WasmOp::I32Const(2),
523 WasmOp::I32Const(3),
524 WasmOp::I32Add,
525 WasmOp::I32Add,
526 ];
527 assert_eq!(max_depth_bound(&ops), 3);
528 assert_eq!(max_depth_bound(&[]), 0);
529 }
530
531 #[test]
532 fn max_depth_bound_over_approximates_unmodeled_ops() {
533 // #587: `call` bails in the underflow checker; the bound treats it as
534 // net +1 (an over-approximation, never an under-count).
535 let ops = vec![WasmOp::I32Const(1), WasmOp::Call(0), WasmOp::I32Add];
536 assert!(max_depth_bound(&ops) >= 2);
537 }
538}