Skip to content

[WebAssembly] Guard EHPadStack against underflow in fixCallUnwindMismatches - #192968

Closed
soilSpoon wants to merge 2 commits into
llvm:mainfrom
soilSpoon:fix-cfg-stackify-shared-cleanuppad
Closed

[WebAssembly] Guard EHPadStack against underflow in fixCallUnwindMismatches#192968
soilSpoon wants to merge 2 commits into
llvm:mainfrom
soilSpoon:fix-cfg-stackify-shared-cleanuppad

Conversation

@soilSpoon

@soilSpoon soilSpoon commented Apr 20, 2026

Copy link
Copy Markdown

When a single cleanup landing pad is reached as the unwind destination of multiple invokes, the reverse walk in fixCallUnwindMismatches() underflows EHPadStack: the pass hits back()/pop_back() on an empty SmallVector and asserts (or segfaults in release). Reproduces on current main with -wasm-enable-eh -wasm-use-legacy-eh=false -exception-model=wasm -mattr=+exception-handling — see the new regression test cfg-stackify-shared-cleanuppad.ll (reduced from a VTK 9.6.1 translation unit with llvm-reduce).

The fix guards the three EHPadStack.back()/pop_back() sites in fixCallUnwindMismatches() that did not already check for emptiness, matching the already-guarded EHPadStack.empty() || ... sites further down in the same function. The post-loop assert(EHPadStack.empty()) is dropped since an empty stack is now a valid terminal state on the shared-pad path. Behaviour is unchanged on inputs where the stack never underflows.

This is likely a workaround rather than a root fix — I haven't pinned down why marker placement leaves the reverse walk unbalanced on the reduced test, only that with these guards the function completes and llc -filetype=obj produces valid output. A more principled fix may belong in placeTryTableMarker/addNestedTryTable so the scan stays balanced; happy to pivot if a reviewer sees the right direction.

A full VTK 9.6.1 -fwasm-exceptions -sWASM_LEGACY_EXCEPTIONS=0 build (150 libraries) compiles with this patch; without it three translation units crash with the same backtrace.

@github-actions

Copy link
Copy Markdown

Thank you for submitting a Pull Request (PR) to the LLVM Project!

This PR will be automatically labeled and the relevant teams will be notified.

If you wish to, you can add reviewers by using the "Reviewers" section on this page.

If this is not working for you, it is probably because you do not have write permissions for the repository. In which case you can instead tag reviewers by name in a comment by using @ followed by their GitHub username.

If you have received no comments on your PR for a week, you can request a review by "ping"ing the PR by adding a comment “Ping”. The common courtesy "ping" rate is once a week. Please remember that you are asking for valuable time from other developers.

If you have further questions, they may be answered by the LLVM GitHub User Guide.

You can also ask questions in a comment on this PR, on the LLVM Discord or on the forums.

@llvmbot

llvmbot commented Apr 20, 2026

Copy link
Copy Markdown
Member

@llvm/pr-subscribers-backend-webassembly

Author: 이대희 (soilSpoon)

Changes

Fixes one of the subcases of #126916.

Summary

When a single cleanup landing pad is reached as the unwind destination of
multiple invokes, the standardized Wasm EH codegen path
(-wasm-use-legacy-eh=0) fails in WebAssemblyCFGStackify::fixCallUnwindMismatches:

  • placeTryTableMarker emits one TRY_TABLE per invoke that unwinds
    to the shared pad, but the pad itself only contains a single catch.
  • fixCallUnwindMismatches walks the function in reverse, pushing on
    each catch and popping on each TRY_TABLE, so the reverse scan
    sees more pops than pushes and underflows EHPadStack.
  • In release, this surfaces as a segfault a few passes later; with
    -verify-machineinstrs it asserts in SmallVector::back() /
    pop_back().

Three EHPadStack access sites in fixCallUnwindMismatches did not
already check for emptiness; this PR guards them, matching the
already-guarded sites elsewhere in the same function. The post-loop
assert(EHPadStack.empty()) invariant is dropped because "no current
scope" is now a valid terminal state when the scan encountered a shared
pad. Behaviour is unchanged when the pad is not shared (the stack never
underflows in that case, so the new guards are no-ops).

Triage note

This is a workaround at the consumer side of the stack; a more
principled fix would arguably restructure placeTryTableMarker so the
reverse scan stays balanced. But the guarded form is small,
self-contained, and makes real downstream builds (VTK 9.6.1 on wasm64,
among others) compile again — whereas the existing #131561 fix, while
correct for its own subcase, does not cover this one.

Reproducer

llvm/test/CodeGen/WebAssembly/cfg-stackify-shared-cleanuppad.ll in
this PR is a reduction from the VTK 9.6.1
vtkFLUENTCFFReader::RequestData translation unit (llvm-reduce
from 2.9 MB → 72 lines). It reproduces the crash on tip LLVM main and
compiles cleanly with this patch applied.

Before this patch:

```
llc: llvm/include/llvm/ADT/SmallVector.h:318: T& back(): Assertion !empty()' failed. ... #<!-- -->10 fixCallUnwindMismatches(...) llvm/lib/Target/WebAssembly/WebAssemblyCFGStackify.cpp:1844 #<!-- -->11 recalculateScopeTops ...:2314 #<!-- -->12 placeMarkers ...:2491 \``

After: llc -filetype=obj exits 0 on the test, and a full VTK build
(150 libraries) then succeeds with
-fwasm-exceptions -sWASM_LEGACY_EXCEPTIONS=0.

Test plan

  • llc builds the new test with -verify-machineinstrs -filetype=obj (both wasm32 and wasm64)
  • Full VTK 9.6.1 rebuild (standardized EH, wasm64) — 150 libs compile
  • Downstream wasm64 embind app links and produces a valid 28 MB module
  • LLVM check-llvm-codegen-webassembly run (will run in CI)

Full diff: https://github.com/llvm/llvm-project/pull/192968.diff

4 Files Affected:

  • (modified) llvm/lib/Target/WebAssembly/WebAssemblyCFGStackify.cpp (+23-9)
  • (modified) llvm/lib/Target/WebAssembly/WebAssemblyRegStackify.cpp (+5-4)
  • (added) llvm/test/CodeGen/WebAssembly/cfg-stackify-shared-cleanuppad.ll (+74)
  • (added) llvm/test/CodeGen/WebAssembly/tee-live-intervals.mir (+41)
diff --git a/llvm/lib/Target/WebAssembly/WebAssemblyCFGStackify.cpp b/llvm/lib/Target/WebAssembly/WebAssemblyCFGStackify.cpp
index 540388cb17b68..f1cd618862440 100644
--- a/llvm/lib/Target/WebAssembly/WebAssemblyCFGStackify.cpp
+++ b/llvm/lib/Target/WebAssembly/WebAssemblyCFGStackify.cpp
@@ -1834,9 +1834,15 @@ bool WebAssemblyCFGStackify::fixCallUnwindMismatches(MachineFunction &MF) {
   for (auto &MBB : reverse(MF)) {
     bool SeenThrowableInstInBB = false;
     for (auto &MI : reverse(MBB)) {
-      if (WebAssembly::isTry(MI.getOpcode()))
-        EHPadStack.pop_back();
-      else if (MI.getOpcode() == WebAssembly::DELEGATE)
+      if (WebAssembly::isTry(MI.getOpcode())) {
+        // A landing pad shared by multiple invokes is reached through
+        // multiple `try_table` markers but only contains one catch, so
+        // the reverse scan can see more `try_table` pops than catch
+        // pushes. Tolerate the underflow: "no current scope" is the
+        // meaningful state for the mismatch check below.
+        if (!EHPadStack.empty())
+          EHPadStack.pop_back();
+      } else if (MI.getOpcode() == WebAssembly::DELEGATE)
         EHPadStack.push_back(MI.getOperand(0).getMBB());
       else if (WebAssembly::isCatch(MI.getOpcode()))
         EHPadStack.push_back(MI.getParent());
@@ -1863,7 +1869,12 @@ bool WebAssemblyCFGStackify::fixCallUnwindMismatches(MachineFunction &MF) {
           break;
         }
       }
-      if (EHPadStack.back() == UnwindDest)
+      // EHPadStack is empty when the invoke is not inside any try scope
+      // (e.g. an outermost-level try_table whose markers haven't wrapped
+      // this call yet). That's a mismatch — the call's unwind edge
+      // doesn't line up with the current scope — so fall through to the
+      // range-recording logic below.
+      if (!EHPadStack.empty() && EHPadStack.back() == UnwindDest)
         continue;
 
       // Include EH_LABELs in the range before and after the invoke
@@ -1942,19 +1953,22 @@ bool WebAssemblyCFGStackify::fixCallUnwindMismatches(MachineFunction &MF) {
       }
 
       // Update EHPadStack.
-      if (WebAssembly::isTry(MI.getOpcode()))
-        EHPadStack.pop_back();
-      else if (MI.getOpcode() == WebAssembly::DELEGATE)
+      if (WebAssembly::isTry(MI.getOpcode())) {
+        if (!EHPadStack.empty())
+          EHPadStack.pop_back();
+      } else if (MI.getOpcode() == WebAssembly::DELEGATE)
         EHPadStack.push_back(MI.getOperand(0).getMBB());
       else if (WebAssembly::isCatch(MI.getOpcode()))
         EHPadStack.push_back(MI.getParent());
     }
 
-    if (RangeEnd)
+    if (RangeEnd && !EHPadStack.empty())
       RecordCallerMismatchRange(EHPadStack.back());
   }
 
-  assert(EHPadStack.empty());
+  // Stack may legitimately stay non-empty when landing pads are shared
+  // across multiple invokes (the reverse walk sees extra `try_table`
+  // pops but only one `catch` push); skip the post-loop empty check.
 
   // We don't have any unwind destination mismatches to resolve.
   if (UnwindDestToTryRanges.empty())
diff --git a/llvm/lib/Target/WebAssembly/WebAssemblyRegStackify.cpp b/llvm/lib/Target/WebAssembly/WebAssemblyRegStackify.cpp
index 97f2ed0a828ba..9afc06848407f 100644
--- a/llvm/lib/Target/WebAssembly/WebAssemblyRegStackify.cpp
+++ b/llvm/lib/Target/WebAssembly/WebAssemblyRegStackify.cpp
@@ -539,10 +539,11 @@ static unsigned getTeeOpcode(const TargetRegisterClass *RC) {
 
 // Shrink LI to its uses, cleaning up LI.
 static void shrinkToUses(LiveInterval &LI, LiveIntervals &LIS) {
-  if (LIS.shrinkToUses(&LI)) {
-    SmallVector<LiveInterval *, 4> SplitLIs;
-    LIS.splitSeparateComponents(LI, SplitLIs);
-  }
+  LIS.shrinkToUses(&LI);
+  // In case the register's live interval now has multiple unconnected
+  // components, split them into multiple registers.
+  SmallVector<LiveInterval *, 4> SplitLIs;
+  LIS.splitSeparateComponents(LI, SplitLIs);
 }
 
 /// A single-use def in the same block with no intervening memory or register
diff --git a/llvm/test/CodeGen/WebAssembly/cfg-stackify-shared-cleanuppad.ll b/llvm/test/CodeGen/WebAssembly/cfg-stackify-shared-cleanuppad.ll
new file mode 100644
index 0000000000000..c81c1694397e2
--- /dev/null
+++ b/llvm/test/CodeGen/WebAssembly/cfg-stackify-shared-cleanuppad.ll
@@ -0,0 +1,74 @@
+; RUN: llc < %s -wasm-enable-eh -wasm-use-legacy-eh=false -exception-model=wasm -mattr=+exception-handling -verify-machineinstrs -filetype=obj -o /dev/null
+; RUN: llc < %s -mtriple=wasm64-unknown-emscripten -wasm-enable-eh -wasm-use-legacy-eh=false -exception-model=wasm -mattr=+atomics,+bulk-memory,+mutable-globals,+sign-ext,+nontrapping-fptoint,+multivalue,+exception-handling -verify-machineinstrs -filetype=obj -o /dev/null
+
+; Regression test for issue #126916 (one of its subcases): a single
+; `cleanuppad` reached as the unwind destination of multiple `invoke`s
+; (bb3 and bb7 both unwind to bb11 below). Under the standardized Wasm
+; EH codegen path, `placeTryTableMarker` emits two `TRY_TABLE` markers
+; for that pad but only one `catch`, so the reverse walk in
+; `fixCallUnwindMismatches` underflows `EHPadStack` when it tries to
+; match the pop_back against the push. Prior to the fix, this crashed
+; with `SmallVector::back()`/`pop_back()` assertions (or a segfault in
+; release builds).
+
+target triple = "wasm32-unknown-unknown"
+
+declare i32 @__gxx_wasm_personality_v0(...)
+declare ptr @llvm.wasm.get.exception(token)
+declare void @f1(ptr, ptr)
+declare ptr @f2(ptr, ptr)
+declare ptr @f3(ptr, ptr)
+declare void @f4(ptr)
+declare i32 @f5(ptr)
+
+define i32 @shared_cleanuppad(ptr %0, i1 %1) personality ptr @__gxx_wasm_personality_v0 {
+  br i1 %1, label %3, label %14
+
+3:
+  invoke void @f1(ptr null, ptr null)
+          to label %5 unwind label %11
+
+5:
+  %6 = invoke ptr @f2(ptr null, ptr null)
+          to label %7 unwind label %9
+
+7:
+  %8 = invoke ptr @f3(ptr null, ptr null)
+          to label %common.ret unwind label %11
+
+common.ret:
+  %common.ret.op = phi i32 [ 0, %23 ], [ 0, %9 ], [ 0, %18 ], [ 0, %26 ], [ 0, %7 ]
+  ret i32 %common.ret.op
+
+9:
+  %10 = cleanuppad within none []
+  br label %common.ret
+
+11:
+  %12 = cleanuppad within none []
+  cleanupret from %12 unwind to caller
+
+14:
+  invoke void @f4(ptr null)
+          to label %26 unwind label %16
+
+16:
+  %17 = catchswitch within none [label %18] unwind to caller
+
+18:
+  %19 = catchpad within %17 [ptr null]
+  %20 = tail call ptr @llvm.wasm.get.exception(token %19)
+  br label %common.ret
+
+21:
+  %22 = catchswitch within none [label %23] unwind to caller
+
+23:
+  %24 = catchpad within %22 [ptr null]
+  %25 = tail call ptr @llvm.wasm.get.exception(token %24)
+  br label %common.ret
+
+26:
+  %28 = invoke i32 @f5(ptr null)
+          to label %common.ret unwind label %21
+}
diff --git a/llvm/test/CodeGen/WebAssembly/tee-live-intervals.mir b/llvm/test/CodeGen/WebAssembly/tee-live-intervals.mir
new file mode 100644
index 0000000000000..b137e990932b0
--- /dev/null
+++ b/llvm/test/CodeGen/WebAssembly/tee-live-intervals.mir
@@ -0,0 +1,41 @@
+# RUN: llc -mtriple=wasm32-unknown-unknown -run-pass wasm-reg-stackify -verify-machineinstrs %s -o -
+
+# TEE generation in RegStackify can create virtual registers with LiveIntervals
+# with multiple disconnected segments, which is invalid in MachineVerifier. In
+# this test, '%0 = CALL @foo' will become a CALL and a TEE, which creates
+# unconnected split segments. This should be later split into multiple
+# registers. This test should not crash with -verify-machineinstrs, which checks
+# whether all segments within a register is connected. See ??? for the detailed
+# explanation.
+
+--- |
+  target triple = "wasm32-unknown-unknown"
+
+  declare ptr @foo(ptr returned)
+  define void @tee_live_intervals_test() {
+    ret void
+  }
+...
+---
+name: tee_live_intervals_test
+liveins:
+  - { reg: '$arguments' }
+tracksRegLiveness: true
+body: |
+  bb.0:
+    liveins: $arguments
+    successors: %bb.1, %bb.2
+    %0:i32 = ARGUMENT_i32 0, implicit $arguments
+    %1:i32 = CONST_I32 0, implicit-def dead $arguments
+    BR_IF %bb.2, %1:i32, implicit-def dead $arguments
+
+  bb.1:
+  ; predecessors: %bb.0
+    %0:i32 = CALL @foo, %0:i32, implicit-def dead $arguments, implicit $sp32, implicit $sp64
+    STORE8_I32_A32 0, 0, %0:i32, %1:i32, implicit-def dead $arguments
+    RETURN %0:i32, implicit-def dead $arguments
+
+  bb.2:
+  ; predecessors: %bb.0
+    %2:i32 = CONST_I32 0, implicit-def dead $arguments
+    RETURN %2:i32, implicit-def dead $arguments

@soilSpoon
soilSpoon force-pushed the fix-cfg-stackify-shared-cleanuppad branch from 0e2df21 to 0291171 Compare April 20, 2026 13:23
…atches

Issue llvm#126916 includes a subcase where a single cleanup landing pad
is reached as the unwind destination of multiple invokes. Under the
standardized Wasm EH codegen path (-wasm-use-legacy-eh=0),
placeTryTableMarker emits a TRY_TABLE marker for each invoke but the
pad only contains a single catch, so the reverse walk in
fixCallUnwindMismatches sees more TRY_TABLE pops than catch pushes
and underflows EHPadStack. In release this surfaces as a segfault a
few passes later; with -verify-machineinstrs it asserts in
SmallVector::back()/pop_back().

Guard the three EHPadStack.back()/pop_back() sites that did not
already check for emptiness and drop the post-loop invariant
assertion, since "no current scope" is now a valid state. Behaviour
is unchanged when the pad is not shared (stack never underflows).

Add a regression test that reduces from a VTK 9.6.1 translation unit
(vtkFLUENTCFFReader::RequestData) to the minimal shape that
reproduces the crash on tip LLVM and no longer crashes with the fix.
@soilSpoon
soilSpoon force-pushed the fix-cfg-stackify-shared-cleanuppad branch from 0291171 to ac43dc2 Compare April 20, 2026 13:26
@aheejin

aheejin commented Apr 25, 2026

Copy link
Copy Markdown
Member

Thanks for the reporting! I submitted #194184 to fix this. Can you test if #194184 compiles the full VTK 9.6.1?

Also, I saw that you added some comments to #131561 and deleted them. Is that related to this issue? Or, do you need that fix for a separate bug? I wish I can land that too, but I somehow dropped it last year.

@soilSpoon

Copy link
Copy Markdown
Author

Tested — see #194184 (comment) for the full A/B numbers. Short version: VTK 9.6.1 builds cleanly with #194184 (4618/4618 ninja steps, 153 libs); without it, 11 TUs in the standardized-EH path crash with the same backtrace. Yours is the right fix — please go ahead with #194184, this PR can be closed.

About #131561: I commented there at first thinking it was the same bug, but on closer look it turned out to be a different issue, so I deleted those comments. No separate fix needed from my side.

aheejin added a commit that referenced this pull request Apr 28, 2026
…194184)

After #187484, `fixCallUnwindMismatches` now runs after
`fixCatchUnwindMismatches`. But `fixCallUnwindMismatches` was not
handling newly generated `try_table`s and trampoline BBs in
`fixCatchUnwindMismatches` correctly. See the comments for details.

This is mean to replace #192968 and adds its test case to
`cfg-stackify-eh.ll` and `cfg-stackify-eh-legacy.ll`. (The bug only
happens in the standard (exnref) EH so `cfg-stackify-eh.ll`, but I added
the same test to `cfg-stackify-eh-legacy.ll` as well for more coverage.)

`exception.ll`'s `inlined_cleanupret`'s results have changed because we
were generating unnecessary `try_table`s, thinking there were call
unwind mismatches when there weren't. (This wouldn't have resulted in
incorrect execution; it just added more unnecessary instructions.) This
also fixes some comments for `inlined_cleanupret`, because "throw_ref
targets the top level cleanuppad" is confusing. (This comment was
probably copy-pasted from "delegate targets ...".)

Closes #192968.
@soilSpoon
soilSpoon deleted the fix-cfg-stackify-shared-cleanuppad branch April 29, 2026 01:30
kirthana14m pushed a commit to ROCm/llvm-project that referenced this pull request Apr 30, 2026
…lvm#194184)

After llvm#187484, `fixCallUnwindMismatches` now runs after
`fixCatchUnwindMismatches`. But `fixCallUnwindMismatches` was not
handling newly generated `try_table`s and trampoline BBs in
`fixCatchUnwindMismatches` correctly. See the comments for details.

This is mean to replace llvm#192968 and adds its test case to
`cfg-stackify-eh.ll` and `cfg-stackify-eh-legacy.ll`. (The bug only
happens in the standard (exnref) EH so `cfg-stackify-eh.ll`, but I added
the same test to `cfg-stackify-eh-legacy.ll` as well for more coverage.)

`exception.ll`'s `inlined_cleanupret`'s results have changed because we
were generating unnecessary `try_table`s, thinking there were call
unwind mismatches when there weren't. (This wouldn't have resulted in
incorrect execution; it just added more unnecessary instructions.) This
also fixes some comments for `inlined_cleanupret`, because "throw_ref
targets the top level cleanuppad" is confusing. (This comment was
probably copy-pasted from "delegate targets ...".)

Closes llvm#192968.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants