[llvm] [SanitizerCoverage] Give calls inserted into funclets their funclet bundle (PR #226762)
Yuma Kakei / DyTect via llvm-commits
llvm-commits at lists.llvm.org
Sun Sep 27 00:12:20 PDT 2026
https://github.com/yumasansansan created https://github.com/llvm/llvm-project/pull/226762
SanitizerCoverage inserts its runtime calls (`__sanitizer_cov_trace_pc_indir`, `__sanitizer_cov_trace_cmp*`, and the rest) without a `"funclet"` operand bundle. Inside a funclet, WinEHPrepare's `removeImplausibleInstructions` treats such a call as one that does not belong to the funclet and replaces it, and the rest of its block, with `unreachable`. With `-fsanitize=fuzzer` on `x86_64-pc-windows-msvc`, a catch handler that makes a virtual call or compares an integer therefore loses its body and falls through into whatever follows the funclet, usually int3 padding, and the fuzzer stops with `STATUS_BREAKPOINT` on inputs that are handled correctly. This is the cause of #212404: the issue describes the symptoms, and the missing bundle is why they occur. It is also what the FIXME in `wineh.ll` describes.
The patch records the runtime calls inserted into each function and, once the function has been instrumented and its blocks split, uses `colorEHFunclets` to give each call inside a funclet the bundle of its pad, as `RuntimeCallInserter` in `AddressSanitizer.cpp` does. `InjectTraceForExits` runs afterwards and is unaffected, since its calls precede returns and resumes, which are never inside a funclet. As in ASan, a call in a block that belongs to more than one funclet is reported with `emitError`, since one bundle cannot name two pads.
Testing: the FIXME in `wineh.ll` becomes checks that the callbacks in the catch and cleanup funclets carry the funclet's token and those in the function's body carry none. The new `funclet-bundle.ll` covers the handler from the issue with the options `-fsanitize=fuzzer` uses (level 4, inline 8-bit counters, trace-compares) and with gated callbacks, where the calls sit in blocks split off inside the funclet. Both tests fail with an unpatched `opt` and pass with the patch, and all 43 tests in `llvm/test/Instrumentation/SanitizerCoverage` pass. Compiled on through `llc -mtriple=x86_64-pc-windows-msvc`, the issue's kind of handler now keeps its whole body in the funclet instead of an empty one.
Found while fuzzing a Matroska demuxer on Windows: libebml's `EbmlMaster::Read` deletes the element in a `catch (...)` before rethrowing, and the virtual destructor call disappeared along with the rethrow.
Assisted-by: Claude Code (Claude Opus 5.5)
>From 3e9d87418a1d0212d25f70d4737b242875cc114b Mon Sep 17 00:00:00 2001
From: yumasansansan <yumasansansan at gmail.com>
Date: Sun, 27 Sep 2026 13:21:45 +0900
Subject: [PATCH] [SanitizerCoverage] Give calls inserted into funclets their
funclet bundle
With scoped EH (MSVC C++), a call inside a funclet must carry a "funclet" operand bundle naming the funclet's pad. SanitizerCoverage inserted its runtime calls without one, so WinEHPrepare's removeImplausibleInstructions took each such call in a catch or cleanup funclet for a call that does not belong to the funclet, and replaced it and the rest of its block with unreachable.
-fsanitize=fuzzer turns on indirect-call and comparison tracing, so a catch handler that compares an integer or makes a virtual call lost everything from that point on: the rest of the handler, the destructor calls and the rethrow. The funclet fell through into whatever followed it, usually int3 padding, and the fuzzer stopped with STATUS_BREAKPOINT on inputs the program handles correctly.
Record the runtime calls inserted into each function and, once it is instrumented, give each one that is in a funclet the bundle of its pad, as AddressSanitizer's RuntimeCallInserter does. The colors come from colorEHFunclets after all blocks have been split, so the calls in the blocks that gated callbacks split off are covered too. InjectTraceForExits runs afterwards and needs nothing: its calls precede returns and resumes, which are never in a funclet.
The FIXME in wineh.ll asked for this and becomes checks; funclet-bundle.ll covers the handler from the issue, with the options -fsanitize=fuzzer uses and with gated callbacks. Both tests fail without the change.
Fixes #212404.
Assisted-by: Claude Code (Claude Opus 5.5)
---
.../Instrumentation/SanitizerCoverage.cpp | 93 +++++++++++++++----
.../SanitizerCoverage/funclet-bundle.ll | 91 ++++++++++++++++++
.../SanitizerCoverage/wineh.ll | 19 +++-
3 files changed, 184 insertions(+), 19 deletions(-)
create mode 100644 llvm/test/Instrumentation/SanitizerCoverage/funclet-bundle.ll
diff --git a/llvm/lib/Transforms/Instrumentation/SanitizerCoverage.cpp b/llvm/lib/Transforms/Instrumentation/SanitizerCoverage.cpp
index 22aef2a846e68..6c1ba5dce3130 100644
--- a/llvm/lib/Transforms/Instrumentation/SanitizerCoverage.cpp
+++ b/llvm/lib/Transforms/Instrumentation/SanitizerCoverage.cpp
@@ -280,6 +280,9 @@ class ModuleSanitizerCoverage {
Value *CreateFunctionLocalGateCmp(IRBuilder<> &IRB);
void InjectCoverageAtBlock(Function &F, BasicBlock &BB, size_t Idx,
Value *&FunctionGateCmp, bool IsLeafFunc);
+ CallInst *createRuntimeCall(IRBuilder<> &IRB, FunctionCallee Callee,
+ ArrayRef<Value *> Args = {});
+ void addFuncletBundles(Function &F);
Function *CreateInitCallsForSections(Module &M, const char *CtorName,
const char *InitFunctionName, Type *Ty,
const char *Section);
@@ -318,6 +321,9 @@ class ModuleSanitizerCoverage {
GlobalVariable *FunctionBoolArray; // for inline-bool-flag.
GlobalVariable *FunctionPCsArray; // for pc-table.
GlobalVariable *FunctionCFsArray; // for control flow table
+ // Runtime calls inserted into the current function, for
+ // addFuncletBundles().
+ SmallVector<CallInst *, 16> RuntimeCalls;
SmallVector<GlobalValue *, 20> GlobalsToAppendToUsed;
SmallVector<GlobalValue *, 20> GlobalsToAppendToCompilerUsed;
@@ -763,11 +769,60 @@ void ModuleSanitizerCoverage::instrumentFunction(Function &F) {
InjectTraceForDiv(F, DivTraceTargets);
InjectTraceForGep(F, GepTraceTargets);
InjectTraceForLoadsAndStores(F, Loads, Stores);
+ // Before InjectTraceForExits(), whose EscapeEnumerator may turn the calls
+ // into invokes. The calls it inserts precede returns and resumes, which are
+ // never in a funclet.
+ addFuncletBundles(F);
if (Options.TracePCEntryExit)
InjectTraceForExits(F);
}
+CallInst *ModuleSanitizerCoverage::createRuntimeCall(IRBuilder<> &IRB,
+ FunctionCallee Callee,
+ ArrayRef<Value *> Args) {
+ CallInst *CI = IRB.CreateCall(Callee, Args);
+ RuntimeCalls.push_back(CI);
+ return CI;
+}
+
+// With scoped EH (MSVC C++), a call inside a funclet must name the funclet's
+// pad in a "funclet" operand bundle. WinEHPrepare takes a call without one for
+// a call that does not belong to the funclet and replaces it, and all that
+// follows it in its block, with unreachable: a catch handler that compares an
+// integer or makes an indirect call would lose its body. Give each runtime
+// call the bundle of the funclet it is in.
+void ModuleSanitizerCoverage::addFuncletBundles(Function &F) {
+ SmallVector<CallInst *, 16> Calls;
+ std::swap(Calls, RuntimeCalls);
+ if (Calls.empty() || !F.hasPersonalityFn() ||
+ !isScopedEHPersonality(classifyEHPersonality(F.getPersonalityFn())))
+ return;
+
+ DenseMap<BasicBlock *, ColorVector> BlockColors = colorEHFunclets(F);
+ for (CallInst *CI : Calls) {
+ const ColorVector &Colors = BlockColors[CI->getParent()];
+ // Unreachable blocks have no color; they are deleted later.
+ if (Colors.empty())
+ continue;
+ // A bundle names one pad.
+ if (Colors.size() != 1) {
+ F.getContext().emitError("Instruction's BasicBlock is not monochromatic");
+ continue;
+ }
+ BasicBlock *Color = Colors.front();
+ BasicBlock::iterator Pad = Color->getFirstNonPHIIt();
+ if (Pad == Color->end() || !Pad->isEHPad())
+ continue;
+ OperandBundleDef OB("funclet", &*Pad);
+ CallBase *NewCall = CallBase::addOperandBundle(CI, LLVMContext::OB_funclet,
+ OB, CI->getIterator());
+ NewCall->copyMetadata(*CI);
+ CI->replaceAllUsesWith(NewCall);
+ CI->eraseFromParent();
+ }
+}
+
GlobalVariable *ModuleSanitizerCoverage::CreateFunctionLocalArrayInSection(
size_t NumElements, Function &F, Type *Ty, const char *Section) {
ArrayType *ArrayTy = ArrayType::get(Ty, NumElements);
@@ -901,7 +956,8 @@ void ModuleSanitizerCoverage::InjectCoverageForIndirectCalls(
Value *Callee = CB.getCalledOperand();
if (isa<InlineAsm>(Callee))
continue;
- IRB.CreateCall(SanCovTracePCIndir, IRB.CreatePointerCast(Callee, IntptrTy));
+ createRuntimeCall(IRB, SanCovTracePCIndir,
+ IRB.CreatePointerCast(Callee, IntptrTy));
}
}
@@ -945,9 +1001,9 @@ void ModuleSanitizerCoverage::InjectTraceForSwitch(
if (Options.GatedCallbacks) {
auto GateBranch = CreateGateBranch(F, FunctionGateCmp, I);
IRBuilder<> GateIRB(GateBranch);
- GateIRB.CreateCall(SanCovTraceSwitchFunction, {Cond, GV});
+ createRuntimeCall(GateIRB, SanCovTraceSwitchFunction, {Cond, GV});
} else {
- IRB.CreateCall(SanCovTraceSwitchFunction, {Cond, GV});
+ createRuntimeCall(IRB, SanCovTraceSwitchFunction, {Cond, GV});
}
}
}
@@ -967,8 +1023,8 @@ void ModuleSanitizerCoverage::InjectTraceForDiv(
if (CallbackIdx < 0)
continue;
auto Ty = Type::getIntNTy(*C, TypeSize);
- IRB.CreateCall(SanCovTraceDivFunction[CallbackIdx],
- {IRB.CreateIntCast(A1, Ty, true)});
+ createRuntimeCall(IRB, SanCovTraceDivFunction[CallbackIdx],
+ {IRB.CreateIntCast(A1, Ty, true)});
}
}
@@ -978,8 +1034,8 @@ void ModuleSanitizerCoverage::InjectTraceForGep(
InstrumentationIRBuilder IRB(GEP);
for (Use &Idx : GEP->indices())
if (!isa<ConstantInt>(Idx) && Idx->getType()->isIntegerTy())
- IRB.CreateCall(SanCovTraceGepFunction,
- {IRB.CreateIntCast(Idx, IntptrTy, true)});
+ createRuntimeCall(IRB, SanCovTraceGepFunction,
+ {IRB.CreateIntCast(Idx, IntptrTy, true)});
}
}
@@ -1000,7 +1056,7 @@ void ModuleSanitizerCoverage::InjectTraceForLoadsAndStores(
int Idx = CallbackIdx(LI->getType());
if (Idx < 0)
continue;
- IRB.CreateCall(SanCovLoadFunction[Idx], Ptr);
+ createRuntimeCall(IRB, SanCovLoadFunction[Idx], Ptr);
}
for (auto *SI : Stores) {
InstrumentationIRBuilder IRB(SI);
@@ -1008,7 +1064,7 @@ void ModuleSanitizerCoverage::InjectTraceForLoadsAndStores(
int Idx = CallbackIdx(SI->getValueOperand()->getType());
if (Idx < 0)
continue;
- IRB.CreateCall(SanCovStoreFunction[Idx], Ptr);
+ createRuntimeCall(IRB, SanCovStoreFunction[Idx], Ptr);
}
}
@@ -1057,11 +1113,13 @@ void ModuleSanitizerCoverage::InjectTraceForCmp(
if (Options.GatedCallbacks) {
auto GateBranch = CreateGateBranch(F, FunctionGateCmp, I);
IRBuilder<> GateIRB(GateBranch);
- GateIRB.CreateCall(CallbackFunc, {GateIRB.CreateIntCast(A0, Ty, true),
- GateIRB.CreateIntCast(A1, Ty, true)});
+ createRuntimeCall(GateIRB, CallbackFunc,
+ {GateIRB.CreateIntCast(A0, Ty, true),
+ GateIRB.CreateIntCast(A1, Ty, true)});
} else {
- IRB.CreateCall(CallbackFunc, {IRB.CreateIntCast(A0, Ty, true),
- IRB.CreateIntCast(A1, Ty, true)});
+ createRuntimeCall(
+ IRB, CallbackFunc,
+ {IRB.CreateIntCast(A0, Ty, true), IRB.CreateIntCast(A1, Ty, true)});
}
}
}
@@ -1090,7 +1148,7 @@ void ModuleSanitizerCoverage::InjectCoverageAtBlock(Function &F, BasicBlock &BB,
FunctionCallee Callee = IsEntryBB && Options.TracePCEntryExit
? SanCovTracePCEntry
: SanCovTracePC;
- IRB.CreateCall(Callee)
+ createRuntimeCall(IRB, Callee)
->setCannotMerge(); // gets the PC using GET_CALLER_PC.
}
if (Options.TracePCGuard) {
@@ -1100,9 +1158,10 @@ void ModuleSanitizerCoverage::InjectCoverageAtBlock(Function &F, BasicBlock &BB,
Instruction *I = &*IP;
auto GateBranch = CreateGateBranch(F, FunctionGateCmp, I);
IRBuilder<> GateIRB(GateBranch);
- GateIRB.CreateCall(SanCovTracePCGuard, GuardPtr)->setCannotMerge();
+ createRuntimeCall(GateIRB, SanCovTracePCGuard, GuardPtr)
+ ->setCannotMerge();
} else {
- IRB.CreateCall(SanCovTracePCGuard, GuardPtr)->setCannotMerge();
+ createRuntimeCall(IRB, SanCovTracePCGuard, GuardPtr)->setCannotMerge();
}
}
if (Options.Inline8bitCounters) {
@@ -1166,7 +1225,7 @@ void ModuleSanitizerCoverage::InjectCoverageAtBlock(Function &F, BasicBlock &BB,
EstimatedStackSize >= Options.StackDepthCallbackMin) {
if (InsertBefore)
IRB.SetInsertPoint(InsertBefore);
- auto Call = IRB.CreateCall(SanCovStackDepthCallback);
+ auto Call = createRuntimeCall(IRB, SanCovStackDepthCallback);
if (EntryLoc)
Call->setDebugLoc(EntryLoc);
Call->setCannotMerge();
diff --git a/llvm/test/Instrumentation/SanitizerCoverage/funclet-bundle.ll b/llvm/test/Instrumentation/SanitizerCoverage/funclet-bundle.ll
new file mode 100644
index 0000000000000..fc559928e533d
--- /dev/null
+++ b/llvm/test/Instrumentation/SanitizerCoverage/funclet-bundle.ll
@@ -0,0 +1,91 @@
+; Calls that SanitizerCoverage inserts into a funclet carry the funclet's
+; "funclet" operand bundle. WinEHPrepare takes a call in a funclet without one
+; for a call that does not belong there, and replaces it and the rest of its
+; block with unreachable: the handler below would lose everything from the
+; comparison on, the destructor call and the rethrow with it.
+
+; The coverage -fsanitize=fuzzer asks for.
+; RUN: opt < %s -passes='module(sancov-module)' -sanitizer-coverage-level=4 -sanitizer-coverage-inline-8bit-counters -sanitizer-coverage-trace-compares -S | FileCheck %s
+; With the callbacks gated, the calls are in blocks split off inside the funclet.
+; RUN: opt < %s -passes='module(sancov-module)' -sanitizer-coverage-level=4 -sanitizer-coverage-trace-pc-guard -sanitizer-coverage-trace-compares -sanitizer-coverage-gated-trace-callbacks -S | FileCheck %s --check-prefix=GATED
+
+; Generated from this C++ source, then renamed:
+; $ clang++ --target=x86_64-pc-windows-msvc -O2 -S -emit-llvm t.cpp
+; struct Element { virtual ~Element(); };
+; void read(Element *element);
+; void read_guarded(Element *element, int &found, int limit) {
+; try {
+; read(element);
+; } catch (...) {
+; if (found > limit)
+; found = 0;
+; delete element;
+; throw;
+; }
+; }
+
+target datalayout = "e-m:w-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128"
+target triple = "x86_64-pc-windows-msvc19.33.0"
+
+define void @"?read_guarded@@YAXPEAUElement@@AEAHH at Z"(ptr %element, ptr %found, i32 %limit) personality ptr @__CxxFrameHandler3 {
+entry:
+ invoke void @"?read@@YAXPEAUElement@@@Z"(ptr %element)
+ to label %return unwind label %catch.dispatch
+
+catch.dispatch:
+ %0 = catchswitch within none [label %catch] unwind to caller
+
+catch:
+ %1 = catchpad within %0 [ptr null, i32 64, ptr null]
+ %2 = load i32, ptr %found, align 4
+ %cmp = icmp sgt i32 %2, %limit
+ br i1 %cmp, label %if.then, label %if.end
+
+if.then:
+ store i32 0, ptr %found, align 4
+ br label %if.end
+
+if.end:
+ %isnull = icmp eq ptr %element, null
+ br i1 %isnull, label %rethrow, label %delete.notnull
+
+delete.notnull:
+ %vtable = load ptr, ptr %element, align 8
+ %dtor = load ptr, ptr %vtable, align 8
+ %call = call ptr %dtor(ptr %element, i32 1) [ "funclet"(token %1) ]
+ br label %rethrow
+
+rethrow:
+ call void @_CxxThrowException(ptr null, ptr null) [ "funclet"(token %1) ]
+ unreachable
+
+return:
+ ret void
+}
+
+; CHECK-LABEL: define void @"?read_guarded@@YAXPEAUElement@@AEAHH at Z"(
+; CHECK: catch:
+; CHECK-NEXT: %[[PAD:[0-9]+]] = catchpad within %{{[0-9]+}} [ptr null, i32 64, ptr null]
+; CHECK: call void @__sanitizer_cov_trace_cmp4(i32 %{{[0-9]+}}, i32 %limit) [ "funclet"(token %[[PAD]]) ]
+; CHECK-NEXT: %cmp = icmp sgt i32 %{{[0-9]+}}, %limit
+; CHECK: delete.notnull:
+; CHECK: call void @__sanitizer_cov_trace_pc_indir(i64 %{{[0-9]+}}) [ "funclet"(token %[[PAD]]) ]
+; CHECK-NEXT: %call = call ptr %dtor(ptr %element, i32 1) [ "funclet"(token %[[PAD]]) ]
+; CHECK: rethrow:
+; CHECK: call void @_CxxThrowException(ptr null, ptr null) [ "funclet"(token %[[PAD]]) ]
+
+; Outside the funclet, no bundle.
+; GATED-LABEL: define void @"?read_guarded@@YAXPEAUElement@@AEAHH at Z"(
+; GATED: call void @__sanitizer_cov_trace_pc_guard(ptr @__sancov_gen_) #{{[0-9]+}}{{$}}
+; GATED: catch:
+; GATED-NEXT: %[[PAD:[0-9]+]] = catchpad within %{{[0-9]+}} [ptr null, i32 64, ptr null]
+; GATED: call void @__sanitizer_cov_trace_cmp4(i32 %{{[0-9]+}}, i32 %limit) [ "funclet"(token %[[PAD]]) ]
+; GATED: call void @__sanitizer_cov_trace_pc_guard({{.*}}) #{{[0-9]+}} [ "funclet"(token %[[PAD]]) ]
+; GATED: call void @__sanitizer_cov_trace_pc_indir(i64 %{{[0-9]+}}) [ "funclet"(token %[[PAD]]) ]
+; GATED-NEXT: %call = call ptr %dtor(ptr %element, i32 1) [ "funclet"(token %[[PAD]]) ]
+; GATED: return:
+; GATED: call void @__sanitizer_cov_trace_pc_guard({{.*}}) #{{[0-9]+}}{{$}}
+
+declare void @"?read@@YAXPEAUElement@@@Z"(ptr)
+declare i32 @__CxxFrameHandler3(...)
+declare void @_CxxThrowException(ptr, ptr)
diff --git a/llvm/test/Instrumentation/SanitizerCoverage/wineh.ll b/llvm/test/Instrumentation/SanitizerCoverage/wineh.ll
index 82ce3ad1a2ec3..a3e719366069c 100644
--- a/llvm/test/Instrumentation/SanitizerCoverage/wineh.ll
+++ b/llvm/test/Instrumentation/SanitizerCoverage/wineh.ll
@@ -17,12 +17,27 @@
; return 0;
; }
-; FIXME: We need to do more than this. In particular, __sanitizer_cov callbacks
-; in funclets need token bundles.
+; The callbacks in the catch and cleanup funclets carry the funclet's token;
+; the ones in the function's body carry none.
; CHECK-LABEL: define i32 @"\01?f@@YAHXZ"()
+; CHECK: entry:
+; CHECK: call void @__sanitizer_cov_trace_pc() #{{[0-9]+}}{{$}}
; CHECK: catch.dispatch:
; CHECK-NEXT: catchswitch within none [label %catch3, label %catch] unwind label %ehcleanup
+; CHECK: catch3:
+; CHECK-NEXT: %[[CATCH3:[0-9]+]] = catchpad within
+; CHECK-NEXT: call void @__sanitizer_cov_trace_pc() #{{[0-9]+}} [ "funclet"(token %[[CATCH3]]) ]
+; CHECK: invoke.cont4:
+; CHECK-NEXT: call void @__sanitizer_cov_trace_pc() #{{[0-9]+}} [ "funclet"(token %[[CATCH3]]) ]
+; CHECK: catch:
+; CHECK-NEXT: %[[CATCH:[0-9]+]] = catchpad within
+; CHECK-NEXT: call void @__sanitizer_cov_trace_pc() #{{[0-9]+}} [ "funclet"(token %[[CATCH]]) ]
+; CHECK: invoke.cont2:
+; CHECK-NEXT: call void @__sanitizer_cov_trace_pc() #{{[0-9]+}} [ "funclet"(token %[[CATCH]]) ]
+; CHECK: ehcleanup:
+; CHECK-NEXT: %[[CLEANUP:[0-9]+]] = cleanuppad within none []
+; CHECK-NEXT: call void @__sanitizer_cov_trace_pc() #{{[0-9]+}} [ "funclet"(token %[[CLEANUP]]) ]
; ModuleID = 't.cpp'
source_filename = "t.cpp"
More information about the llvm-commits
mailing list