fitzgen opened issue #14573:
Dead-store elimination removes a store
S1when a later storeS2to the
same location post-dominates it. It does this even whenS1can trap,
providedS2has the same trap code. That is only sound ifS2actually
executes afterS1. When control afterS1loops forever,S1's trap is
the only way out, and removing it turns a trap into a hang.Linear-memory stores with signals-based bounds checks can trap, so plain Wasm
hits this with Wasmtime's default configuration. The same thing happens when
the loop has an exit edge but simply never takes it at runtime, e.g.
(loop (br_if 0 (local.get $spin))).Test Case
(module (memory 1 1) (func (export "f") (param $addr i32) (param $spin i32) (i32.store (local.get $addr) (i32.const 1)) (if (local.get $spin) (then (loop (br 0)))) (i32.store (local.get $addr) (i32.const 2)))) (assert_trap (invoke "f" (i32.const 65536) (i32.const 1)) "out of bounds memory access")Steps to Reproduce
wasmtime wast test.wastExpected Results
ftraps at the first store with an out-of-bounds memory access, as it does
with-O opt-level=0.Actual Results
fhangs forever. The firsti32.storehas been removed from the compiled
code.Versions and Environment
Wasmtime version or commit:
73b04cff33Operating system: macOS 15.8.1
Architecture: aarch64
fitzgen added the bug label to Issue #14573.
fitzgen added the cranelift label to Issue #14573.
fitzgen commented on issue #14573:
<details><summary>Full LLM report</summary>
Dead-store elimination deletes a trapping store when a non-terminating loop precedes its overwriter
Date 2026-10-05 Wasmtime commit 73b04cff3317d1e308866eb24359483ac6116669(main)Host macOS 15.8.1 (Darwin 24.6.0), aarch64-apple-darwinModel Claude Opus 5.5 ( claude-opus-5-5)Component cranelift/codegen/src/alias_analysis.rs(dead-store elimination)Class Miscompile: a guaranteed trap becomes an infinite loop Severity Medium. Reachable from plain core Wasm with Wasmtime's default config. The store is still bounds-checked, so this is not a sandbox escape, but a program that must trap hangs instead. That defeats trap-based termination and turns an immediate error into resource exhaustion that only epochs or fuel can stop. Summary
Dead-store elimination removes a store
S1when a later storeS2writes the
same bytes andS2post-dominatesS1
(alias_analysis.rs:1194-1219,post_dominates_maybe_dead_storeat:859).
fully_overwrites(:1580-1590) letsS1be a trapping store, as long as
S2has the same trap code. Its comment explains that the condition exists so
that removal cannot change "which code an execution traps with".That argument only works if
S2actually executes wheneverS1does.
Post-domination does not guarantee that, because it is a statement about paths
that reach an exit. If execution can run forever betweenS1andS2, then
S1's trap is the only way that execution ends. DeletingS1turns
"trap now" into "hang forever".This happens in three shapes, all with the same root cause:
The loop never exits.
PostDominatorTreeis rooted only at exit blocks
(post_dominator_tree.rs:41-55). Blocks that cannot reach an exit are
invisible to it, so the path through an emptyjump block2self-loop does
not count against post-domination.The fix for #14053 (
0b0820c441,compute_observed_stores,
alias_analysis.rs:1028-1051) only rescues the store when some
instruction on that path observes it. An empty loop observes nothing.The loop has an exit edge but does not terminate at runtime. An example
is(loop (br_if 0 (local.get $spin))). HereS2genuinely post-dominates
S1, so making the post-dominator tree divergence-aware would not fix
this case. Post-domination is simply the wrong criterion for removing a
store that can trap.Both stores sit in blocks that never reach an exit.
DominatorTree::block_dominates(dominator_tree.rs:360-364) compares
pre-order numbers. Blocks that the post-dominator traversal never visits
keep their default numbers of 0. So0 <= 0 && 0 >= 0claims
post-domination for every such pair. The mechanism was established by
reading the code; the store deletion itself is reproduced by
optimize-both-divergent.clif.In Wasmtime, every linear-memory store compiled under the default
signals-based bounds checks is a trapping store (MemoryOutOfBounds), so
ordinary Wasm hits this.Reproduction
Wasm (
repro.wast)(module (memory 1 1) (func (export "f") (param $addr i32) (param $spin i32) (i32.store (local.get $addr) (i32.const 1)) (if (local.get $spin) (then (loop (br 0)))) (i32.store (local.get $addr) (i32.const 2)))) (assert_trap (invoke "f" (i32.const 65536) (i32.const 1)) "out of bounds memory access")Each configuration was run with a 10-second
timeout:
Command Result wasmtime wast -O opt-level=0 repro.wastpasses (traps) wasmtime wast repro.wast(default)hangs (timeout, exit 124) -C inlining=y,-O memory-guard-size=0hangs -W epoch-interruption=y,-W fuel=...,-O signals-based-traps=npasses. In each case something inside the loop (an epoch check, a fuel check, or an explicit bounds-check trapnz) observes the store, so it is kept.
repro-loop-with-exit.wastis shape 2. It uses
(loop (br_if 0 (local.get $spin)))in place of theif. It passes at
opt-level=0and hangs by default.wasmtime objdumpof the default build
shows only the secondstr.CLIF
File What it shows optimize.clifshape 1. test optimize precise-output: the expected output keeps the firststore user1, but the diff shows it deleted.optimize-loop-with-exit.clifshape 2. Same diff. optimize-both-divergent.clifshape 3. Same diff. interpret-original.cliftest interpretof the input; it trapsTrap(User(TrapCode(1))).interpret-optimized.cliftest interpretof the optimizer's output; it never finishes (killed bytimeout).interpret-loop-with-exit-{original,optimized}.clifthe same interpreter pair for shape 2. $ clif-util test optimize.clif - store.i32 user1 aligned region0 v1, v0 Error: 1 failure $ clif-util test interpret-original.clif Unexpected returned control flow: Trap(User(TrapCode(1))) $ timeout 10 clif-util test interpret-optimized.clif ; echo $? 124Related variant: non-trapping stores to shared memory
The same deletion happens to a store that cannot trap, if it writes a
sharedmemory (related/shared-memory.wat):(memory (export "mem") 1 1 shared) (func (export "f") (param i32) (i32.store (i32.const 0) (i32.const 1)) (if (local.get 0) (then (loop (br 0)))) (i32.store (i32.const 0) (i32.const 2)))$ wasmtime compile -W threads=y related/shared-memory.wat -o f.cwasm $ wasmtime objdump f.cwasm | grep -c '\bstr\b' 1 # the first store is goneWhile this thread spins, another thread can read address 0 and would never see
the value1that the program wrote. Demonstrating that at run time needs a
multi-threaded harness, so this report does not rely on it. The fix below is
meant to cover it as well.Suggested fix
Do not use post-domination alone to justify deleting a store that can trap. A
trappingmaybe_deadstore may only be removed when execution is guaranteed
to reach the overwriter. Some options, from simplest to most precise:
Only eliminate trapping stores when
overwriteris in the same block, after
maybe_dead. That is the existing fast path atalias_analysis.rs:877.
Straight-line code cannot diverge, apart from calls, and calls already
observe the store.Allow cross-block elimination of trapping stores only when no cycle is
reachable frommaybe_deadwithout first passing throughoverwriter.
That rules out every path that might not terminate.(Preferred.) Have
compute_observed_stores(alias_analysis.rs:1028-1051)
treat each loop back edge (or loop header) as observing every store in its
LastStores. This mirrors the existing rule that a lossy meet observes the
predecessor's last store. It fixes all three shapes and the shared-memory
variant: a store can then only die against an overwriter that is reached
without going round a loop. The cost is giving up DSE across loops; if that
matters, restrict the rule to stores that can trap or that write shared
memory.Independently,
PostDominatorTree::block_post_dominatesshould returnfalse
when either block is unreachable in the reversed graph. Today it returns
true, which is the cause of shape 3. Its oracle test
(post_dominators_match_oracle) skips blocks that diverge, which is why this
was never caught.Two regression tests should be added: the three CLIF shapes as
test optimizefiletests, andrepro.wast/repro-loop-with-exit.wastas
misc-testsuite tests.</details>
cfallin closed issue #14573:
Dead-store elimination removes a store
S1when a later storeS2to the
same location post-dominates it. It does this even whenS1can trap,
providedS2has the same trap code. That is only sound ifS2actually
executes afterS1. When control afterS1loops forever,S1's trap is
the only way out, and removing it turns a trap into a hang.Linear-memory stores with signals-based bounds checks can trap, so plain Wasm
hits this with Wasmtime's default configuration. The same thing happens when
the loop has an exit edge but simply never takes it at runtime, e.g.
(loop (br_if 0 (local.get $spin))).Test Case
(module (memory 1 1) (func (export "f") (param $addr i32) (param $spin i32) (i32.store (local.get $addr) (i32.const 1)) (if (local.get $spin) (then (loop (br 0)))) (i32.store (local.get $addr) (i32.const 2)))) (assert_trap (invoke "f" (i32.const 65536) (i32.const 1)) "out of bounds memory access")Steps to Reproduce
wasmtime wast test.wastExpected Results
ftraps at the first store with an out-of-bounds memory access, as it does
with-O opt-level=0.Actual Results
fhangs forever. The firsti32.storehas been removed from the compiled
code.Versions and Environment
Wasmtime version or commit:
73b04cff33Operating system: macOS 15.8.1
Architecture: aarch64
Last updated: Oct 11 2026 at 04:10 UTC