Stream: git-wasmtime

Topic: wasmtime / issue #14573 Cranelift: dead-store elimination...


view this post on Zulip Wasmtime GitHub notifications bot (Oct 06 2026 at 15:22):

fitzgen opened issue #14573:

Dead-store elimination removes a store S1 when a later store S2 to the
same location post-dominates it. It does this even when S1 can trap,
provided S2 has the same trap code. That is only sound if S2 actually
executes after S1. When control after S1 loops 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.wast

Expected Results

f traps at the first store with an out-of-bounds memory access, as it does
with -O opt-level=0.

Actual Results

f hangs forever. The first i32.store has been removed from the compiled
code.

Versions and Environment

Wasmtime version or commit: 73b04cff33

Operating system: macOS 15.8.1

Architecture: aarch64

view this post on Zulip Wasmtime GitHub notifications bot (Oct 06 2026 at 15:22):

fitzgen added the bug label to Issue #14573.

view this post on Zulip Wasmtime GitHub notifications bot (Oct 06 2026 at 15:22):

fitzgen added the cranelift label to Issue #14573.

view this post on Zulip Wasmtime GitHub notifications bot (Oct 06 2026 at 15:23):

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-darwin
Model 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 S1 when a later store S2 writes the
same bytes and S2 post-dominates S1
(alias_analysis.rs:1194-1219, post_dominates_maybe_dead_store at :859).
fully_overwrites (:1580-1590) lets S1 be a trapping store, as long as
S2 has 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 S2 actually executes whenever S1 does.
Post-domination does not guarantee that, because it is a statement about paths
that reach an exit. If execution can run forever between S1 and S2, then
S1's trap is the only way that execution ends. Deleting S1 turns
"trap now" into "hang forever".

This happens in three shapes, all with the same root cause:

  1. The loop never exits. PostDominatorTree is 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 empty jump block2 self-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.

  2. The loop has an exit edge but does not terminate at runtime. An example
    is (loop (br_if 0 (local.get $spin))). Here S2 genuinely 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.

  3. 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. So 0 <= 0 && 0 >= 0 claims
    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.wast passes (traps)
wasmtime wast repro.wast (default) hangs (timeout, exit 124)
-C inlining=y, -O memory-guard-size=0 hangs
-W epoch-interruption=y, -W fuel=..., -O signals-based-traps=n passes. 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.wast is shape 2. It uses
(loop (br_if 0 (local.get $spin))) in place of the if. It passes at
opt-level=0 and hangs by default. wasmtime objdump of the default build
shows only the second str.

CLIF

File What it shows
optimize.clif shape 1. test optimize precise-output: the expected output keeps the first store user1, but the diff shows it deleted.
optimize-loop-with-exit.clif shape 2. Same diff.
optimize-both-divergent.clif shape 3. Same diff.
interpret-original.clif test interpret of the input; it traps Trap(User(TrapCode(1))).
interpret-optimized.clif test interpret of the optimizer's output; it never finishes (killed by timeout).
interpret-loop-with-exit-{original,optimized}.clif the 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 $?
124

Related variant: non-trapping stores to shared memory

The same deletion happens to a store that cannot trap, if it writes a
shared memory (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 gone

While this thread spins, another thread can read address 0 and would never see
the value 1 that 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
trapping maybe_dead store may only be removed when execution is guaranteed
to reach the overwriter. Some options, from simplest to most precise:

Independently, PostDominatorTree::block_post_dominates should return false
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 optimize filetests, and repro.wast / repro-loop-with-exit.wast as
misc-testsuite tests.

</details>

view this post on Zulip Wasmtime GitHub notifications bot (Oct 09 2026 at 21:56):

cfallin closed issue #14573:

Dead-store elimination removes a store S1 when a later store S2 to the
same location post-dominates it. It does this even when S1 can trap,
provided S2 has the same trap code. That is only sound if S2 actually
executes after S1. When control after S1 loops 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.wast

Expected Results

f traps at the first store with an out-of-bounds memory access, as it does
with -O opt-level=0.

Actual Results

f hangs forever. The first i32.store has been removed from the compiled
code.

Versions and Environment

Wasmtime version or commit: 73b04cff33

Operating system: macOS 15.8.1

Architecture: aarch64


Last updated: Oct 11 2026 at 04:10 UTC