llvm / llvm/llvm-project

[WebAssembly] Assertion error when compiling code with coroutines

Open
#208,409 5 comments 0 reactions 1 assignee Claimed by @aheejin View on GitHub
backend:WebAssembly confirmed coroutines crash-on-valid regression:18
Dominant language
LLVM
Stars
40.5k
Forks
18.7k
PR merge metrics
PR metrics pending

Description

Disclaimer: This report was created with a lot of help from AI. I tried my best to confirm every claim in this report, but when it comes to LLVM terminology (which I'm not familiar with) I have to trust it.

When a function's unwind edges form an irreducible cycle that includes EH pads, `WebAssemblyFixIrreducibleControlFlow` rewrites the CFG in a way that violates the invariants the wasm EH lowering passes rely on. The same 40-line, verifier-valid IR module crashes `llc` in both EH modes, at every optimization level (-O0/-O1/-O2), for wasm32 and wasm64, on both `-emscripten` and `-unknown` OS triples:

- **Standard (exnref) EH** (`-wasm-use-legacy-eh=0`):
```
llc: .../WebAssemblyUtilities.cpp:150: llvm::MachineInstr* llvm::WebAssembly::findCatch(llvm::MachineBasicBlock*):
Assertion `EHPad->isEHPad()' failed.
...
2. Running pass 'WebAssembly Late Prepare Exception' on function '@crash'
```
- **Legacy EH** (current default):
```
llc: .../WebAssemblyCFGStackify.cpp:517: void {anonymous}::WebAssemblyCFGStackify::placeTryMarker(llvm::MachineBasicBlock&):
Assertion `!explicitlyBranchesTo(Pred, &MBB) && "Explicit branch to an EH pad!"' failed.
```

On release (no-assert) builds (e.g. the emsdk 6.0.2 toolchain) the minimal module below happens to compile without visible failure, but on real-world inputs of the same shape (the `boost::asio` coroutine code this was reduced from) the violated invariants surface as:
- new EH: segfault in `WebAssemblyLateEHPrepare::runOnMachineFunction` (reproduced with the 9.7 MB pre-reduction IR of the same function), and
- legacy EH: silent emission of a spec-invalid module. Both `wasm-opt` and V8 reject the linked output with `rethrow not targeting catch or catch-all`.

Reproduces on `main` (`777ec1fff4502f6913818de5970a1c060a966877`, 2026-07-08) and on 23.0.0git `787619a4072e0eb7887357d5d284e86c17548aed` (emsdk 6.0.2).

## Reproduce

```
llc -exception-model=wasm -wasm-enable-eh -wasm-use-legacy-eh=0 minimal.ll -o /dev/null # findCatch assert
llc -exception-model=wasm -wasm-enable-eh minimal.ll -o /dev/null # CFGStackify assert
```

```llvm
target datalayout = "e-m:e-p:64:64-p10:8:8-p20:8:8-i64:64-i128:128-f128:64-n32:64-S128-ni:1:10:20"
target triple = "wasm64-unknown-emscripten"

declare i32 @__gxx_wasm_personality_v0(...)
declare ptr @llvm.wasm.get.exception(token)
declare i32 @llvm.wasm.get.ehselector(token)
declare i32 @f(ptr)
declare void @g(ptr)
declare i1 @h(i32)

define void @crash() #0 personality ptr @__gxx_wasm_personality_v0 {
entry:
%0 = invoke i32 @f(ptr null)
to label %cont unwind label %cleanup.outer

cont:
invoke void @g(ptr null)
to label %exit2 unwind label %catch.dispatch

cleanup.outer: ; unwinds INTO the catchswitch below
%pad = cleanuppad within none []
cleanupret from %pad unwind label %catch.dispatch

catch.dispatch:
%cs = catchswitch within none [label %catch.start] unwind label %cleanup.final

catch.start:
%cp = catchpad within %cs [ptr null, ptr null, ptr null]
%exn = tail call ptr @llvm.wasm.get.exception(token %cp)
%sel = tail call i32 @llvm.wasm.get.ehselector(token %cp)
catchret from %cp to label %after.catch

after.catch: ; unwind edge goes BACK to cleanup.outer (cycle)
%1 = invoke i1 @h(i32 0)
to label %exit1 unwind label %cleanup.outer

exit1:
ret void

exit2:
ret void

cleanup.final:
%pad2 = cleanuppad within none []
ret void
}

attributes #0 = { "target-features"="+exception-handling" }
```

Alternatively here C++ that results in the crash:
```cpp
#include

struct task {
struct promise_type {
task get_return_object() {
return {std::coroutine_handle::from_promise(*this)};
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
std::coroutine_handle handle;
};

bool ready(); // whether the awaited value is available synchronously
int value(); // produces the awaited value; may throw
void note(int); // records an observable event

struct awaitable {
~awaitable() { note(-1); }
bool await_ready() { return ready(); }
void await_suspend(std::coroutine_handle<>) {}
int await_resume() { return value(); }
};

task run() {
for (int i = 0; i < 4; ++i) {
try {
int v = co_await awaitable{};
note(v);
} catch (int e) {
note(e);
}
}
}
```

## Analysis

The unwind edges form a loop with two distinct entries, i.e. an irreducible cycle whose members include EH pads:

```
entry --invoke @f--> unwinds to %cleanup.outer (entry 1)
cont --invoke @g--> unwinds to %catch.dispatch (entry 2)
%cleanup.outer --cleanupret--> unwinds to %catch.dispatch
%catch.start --catchret--> %after.catch
%after.catch --invoke @h--> unwinds back to %cleanup.outer (closes the cycle)
```

`WebAssemblyFixIrreducibleControlFlow` "fixes" this by routing all entries of the loop `{cleanup.outer, catch.start, ...}` through a new `BR_TABLE` dispatch block with a discriminator register.
MIR right before `wasm-late-eh-prepare` (`-print-before=wasm-late-eh-prepare`, excerpt):

```
bb.2.cleanup.outer (landing-pad, ehscope-entry):
; predecessors: %bb.8
CLEANUPRET %bb.10, implicit-def dead $arguments <-- MBB operand is NOT an EH pad

bb.8:
; predecessors: %bb.9, %bb.10, %bb.11, %bb.3
BR_TABLE_I32 %11:i32, %bb.2, %bb.4, %bb.4 <-- explicit branches to EH pads
bb.2/bb.4 (both landing-pads)
bb.10:
; predecessors: %bb.5
%11:i32 = CONST_I32 0
BR %bb.8
```

This breaks two invariants:

1. `CLEANUPRET`/`RETHROW`'s MBB operand must reference an EH pad. `WebAssemblyLateEHPrepare::replaceFuncletReturns` turns the `CLEANUPRET` into `RETHROW %bb.10`, and `addCatchRefsAndThrowRefs` then calls `WebAssembly::findCatch(bb.10)` → `assert(EHPad->isEHPad())` fires (WebAssemblyUtilities.cpp:150). In no-assert builds the subsequent iteration over a non-EH-pad block leads to the segfault.
2. Nothing may branch explicitly to an EH pad. The new `BR_TABLE` does exactly that, which trips `placeTryMarker`'s `!explicitlyBranchesTo(Pred, &MBB)` assertion in CFGStackify under legacy EH. On the larger original input, the no-assert emsdk compiler proceeds past the violated invariant and emits `rethrow` instructions whose label doesn't target a `catch`, i.e. a module both Binaryen and V8 reject:
```
[wasm-validator error in function N] unexpected false: all rethrow targets must be valid, on (rethrow $label7)
CompileError: WebAssembly.instantiate(): ... rethrow not targeting catch or catch-all
```

## In the wild

Reduced with `llvm-reduce` from the coroutine resume function of a `boost::asio`/`boost::beast` C++20 awaitable [Live code here](https://github.com/ad-freiburg/qlever/blob/e8506158eda09ae7c30ec845ce2442790e25b038/test/HttpTest.cpp), compiled with emscripten `-fwasm-exceptions` (emsdk 6.0.2, wasm64, `-pthread`).

Possibly related to #126916.

## Environment

- LLVM main `777ec1fff4502f6913818de5970a1c060a966877` (2026-07-08), x86_64 Linux host, `-DLLVM_TARGETS_TO_BUILD=WebAssembly`
- Also: clang 23.0.0git `787619a4072e0eb7887357d5d284e86c17548aed` as shipped in emsdk 6.0.2

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.