Skip to content

Fix mayNotReturn not checked in invalidates()#8308

Closed
sumleo wants to merge 1 commit intoWebAssembly:mainfrom
sumleo:fix-effects-maynotreturn
Closed

Fix mayNotReturn not checked in invalidates()#8308
sumleo wants to merge 1 commit intoWebAssembly:mainfrom
sumleo:fix-effects-maynotreturn

Conversation

@sumleo
Copy link
Contributor

@sumleo sumleo commented Feb 12, 2026

Summary

EffectAnalyzer::invalidates() checks transfersControlFlow() to prevent reordering side-effecting expressions, but transfersControlFlow() does not include mayNotReturn. This means a potentially-infinite loop (which has mayNotReturn=true but transfersControlFlow()=false) can be incorrectly reordered with side-effecting code.

Concrete miscompilation: OptimizeInstructions flips select arms when the condition is i32.eqz, calling canReorder() to check safety. With a loop in one arm and a global.set in the other, canReorder() returns true (incorrectly), causing the global.set to always execute when it should only execute if the loop terminates.

;; Before (correct): global.set only runs if loop terminates
(select
  (block (result i32) (loop $L (br_if $L (local.get $x))) (i32.const 1))
  (block (result i32) (global.set $g (i32.const 42)) (i32.const 2))
  (i32.eqz (local.get $x)))

;; After optimize-instructions (WRONG): global.set always runs
(select
  (block (result i32) (global.set $g (i32.const 42)) (i32.const 2))
  (block (result i32) (loop $L (br_if $L (local.get $x))) (i32.const 1))
  (local.get $x))

Fix: Add mayNotReturn checks alongside the existing transfersControlFlow() checks in invalidates().

Test plan

  • New lit test optimize-instructions-maynotreturn.wast covering:
    • Loop arm with global.set arm: arms NOT swapped
    • Loop arm with no-side-effect arm: arms still swapped (optimization preserved)
    • Loop arm with memory store arm: arms NOT swapped
  • All 309 unit tests pass

The invalidates() method in EffectAnalyzer did not consider
mayNotReturn when checking if two expressions can be reordered.
This allowed optimizations like the select arm swap in
OptimizeInstructions to incorrectly reorder a potentially-infinite
loop with side-effecting code (e.g., global.set), causing the side
effect to always execute when it should have been conditional on
the loop terminating.

Add mayNotReturn checks against writesGlobalState() in
invalidates(). We use writesGlobalState() rather than
hasSideEffects() because mayNotReturn only matters for externally
observable state changes (globals, memory, tables, structs, arrays,
atomics, calls) -- local variable operations are safe to reorder
past a may-not-return expression since they are not observable if
execution never continues.
@sumleo sumleo force-pushed the fix-effects-maynotreturn branch from 96ec337 to b5743f6 Compare February 12, 2026 13:51
@kripken
Copy link
Member

kripken commented Feb 12, 2026

While this is technically true in a way, I don't think we need to change things here.

The only way to observe the difference in the example given is if you run the program and pause it in a debugger or some other such tool, when it is infinite-looping. Pausing in a debugger can notice all sorts of other internal changes in the module, for example, reordering two local.sets leads to debugger-observable changes but not user-observable ones. Binaryen only preserves the latter - at least, I've not heard a strong enough reason for us to do anything more, and it has downsides.

Concretely, the infinite loop is exactly what prevents userspace from noticing a change to that global. There is simply no opportunity for other code to be influenced. (If the global were shared, an argument could be made that another thread could observe it, but cross-thread update timing is nondeterministic, so I'm not sure even then.)

With that said, this is non-obvious behavior, so (1) I'd be open to hearing other opinions, and (2) we should document this better.

@sumleo
Copy link
Contributor Author

sumleo commented Feb 12, 2026

Thanks for the detailed explanation. That makes sense — an infinite loop prevents any downstream code from observing the global state change, so the reordering is semantically valid from the perspective of user-observable behavior. I hadn't considered that the non-termination itself acts as the observation barrier.

I'll close this PR. Thanks for taking the time to explain the reasoning.

@sumleo sumleo closed this Feb 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants