필사 모드: Clang 22's MSVC ABI Change — the Vector Deleting Destructor and the delete[] Heap Corruption Still Alive in 22.1.x
English- Introduction — An Issue Filed the Day After Release
- In the MSVC ABI, There Isn't Just One Destructor
- What Clang Couldn't Land for 11 Years
- The Two Things Clang 22 Changed
- After the Release — Two Bugs, One Backport
- Why It Links Fine but Breaks the Heap at Runtime
- So, What Should You Check Right Now
- Closing
- References
Introduction — An Issue Filed the Day After Release
LLVM/Clang 22.1.0 was released on February 24, 2026. The very next day, February 25, this issue was filed — #183255 "[Clang-Cl] Wrong destructing code generated in Clang 22".
The repro code could not be smaller. A class with a virtual destructor is declared in a header, the destructor is defined in a separate .cpp, and a different .cpp allocates it as an array and deletes it.
// inc.h
class Class1 {};
class Class2 : public Class1 {
public:
Class2();
virtual ~Class2();
};
// impl.cpp — the constructor/destructor definitions live only here
Class2::Class2() {}
Class2::~Class2() {}
// main.cpp
int main() {
Class2* p1 = new Class2;
delete p1; // this one is fine
Class2* p2 = new Class2[1];
delete [] p2; // Debug: RtlValidateHeap assertion / Release: crash
return 0;
}
In the reporter's own words, no custom compiler flags were used, and this does not happen on Clang 21 or 20. A follow-up comment narrowed the scope further — it only happens when targeting MSVC (both x86 and x86_64), and neither Linux nor MinGW is affected.
What happened is that Clang 22 changed destructor codegen in two places when targeting the MSVC ABI. Both were "fixing" changes, and both were ABI changes. This post lays out what those changes are, why they took 11 years, why the link succeeds cleanly but the heap breaks at runtime, and what you need to check right now.
In the MSVC ABI, There Isn't Just One Destructor
First, some background. If you're familiar with the Itanium ABI (Linux, macOS, MinGW), you'll know destructors come in three variants — complete/base/deleting. The MSVC ABI also has multiple variants, but with different names and conventions. Clang's MicrosoftMangle.cpp spells out the mangling verbatim, comments included.
// <operator-name> ::= ?_D # vbase destructor
case Dtor_Complete: Out << "?_D"; return;
// <operator-name> ::= ?_G # scalar deleting destructor
case Dtor_Deleting: Out << "?_G"; return;
// <operator-name> ::= ?_E # vector deleting destructor
case Dtor_VectorDeleting:
Out << "?_E";
return;
To sum up: ??_D is the vbase destructor, ??_G is the scalar deleting destructor, and ??_E is the vector deleting destructor. What matters is that the two deleting-destructor variants take one additional hidden parameter that isn't present in the AST. A comment in the same file explains it — this extra argument indicates whether to free the object's storage and whether it is destroying a single object or an array of objects, and it isn't reflected in the AST. That's why, on 64-bit, the mangling of a deleting destructor ends in PEAXI@Z — that I is the integer argument that isn't in the AST. In the same position, the vbase destructor ends in XXZ. Inside clang, this parameter is materialized as an argument named should_call_delete of type Context.IntTy.
The bit semantics of that flag are laid bare at the call site in MicrosoftCXXABI.cpp.
llvm::Value *ImplicitParam =
CGF.Builder.getInt32((IsDeleting ? 1 : 0) | (IsGlobalDelete ? 4 : 0) |
(IsArrayDelete ? 2 : 0));
- Bit 0 (value 1) — free the storage
- Bit 1 (value 2) — it's an array
- Bit 2 (value 4) — call the global
operator delete(i.e., it was::delete)
So ::delete becomes 5 (=1|4), and ::delete[] becomes 7 (=1|2|4).
Why it's built this way is because MSVC has an extension. Issue #19772, opened in 2014, describes it precisely — MSVC supports an extension that lets you delete[] an array of polymorphic objects even when the array's static type and dynamic type don't match. In other words, delete[] a works even when what you received as A* is actually a B[]. In standard C++ this is UB, but MSVC lets it work, and to do so it puts ??_E — which "knows how to handle arrays too" — into the destructor slot of the vftable.
What Clang Couldn't Land for 11 Years
The problem is that Clang put ??_G (the scalar one) in that slot. Issue #19772 was opened on April 11, 2014. It was closed on November 13, 2025 — it took 11 years and 7 months.
In the meantime, Clang lived with vftable contents that diverged from MSVC's. The description of PR #170337, which finally landed this feature, summarizes the situation — because MSVC's virtual table always carries a vector deleting destructor pointer for classes with a virtual destructor, not implementing this extension means clang emits code that is incompatible with code MSVC produces. That's because clang always puts a scalar deleting destructor pointer in the vtable. It adds, as a bonus, that deleting an array of polymorphic objects now behaves identically to MSVC — without memory leaks, and with the correct destructor called.
Why it took 11 years is a story the commit history tells. This feature landed four times and got reverted three times.
| PR / Commit | Date | What happened |
|---|---|---|
| #126240 | 2025-03-04 | Initial landing |
| (revert commit) | 2025-03-12 | Reverted — Chromium sanitizer coverage link error |
| #133451 | 2025-03-31 | Reland |
| #135611 | 2025-04-14 | Reverted — operator delete[] lookup was a security hazard |
| #165598 | 2025-11-13 | Reland (issue #19772 closed) |
| #169116 | 2025-11-22 | Reverted — broke Chromium build |
| #170337 | 2025-12-12 | Reland (final) |
| #172513 | 2025-12-17 | Fixed the size passed to delete[] |
Each revert's rationale is worth reading.
The first revert (2025-03-12) happened because of Chromium. It's spelled out plainly in the description of the reland PR, #133451 — it was reverted because Chromium, built with sanitizer coverage enabled, hit a link-time error, and that was fixed separately in PR #131929. The same description adds that the second commit of this reland also contains a fix for a Chromium runtime failure reported in the original PR's comments.
The second revert (#135611, 2025-04-14) happened for security reasons. It states that finding operator delete[] was still an unsolved problem, and without it this extension was a security hazard, so it was reverted until the operator delete[] problem got resolved.
The third revert (#169116, 2025-11-22) was, once again, because of Chromium. The revert that actually got merged is #169116, and its body points to a discussion comment on #165598, saying it reverts the offending commit along with two commits that depend on it. A same-day revert PR that never got merged, #169063, states the reason more bluntly — it broke Chromium builds that use /Zc:DllexportInlines-.
So Chromium blocked this feature twice. In March, with a link error from sanitizer coverage; in November, with /Zc:DllexportInlines-. A large real-world codebase effectively served as the integration test for this ABI change.
And #172513, which came five days after the final reland, states that EmitDeleteCall was missing a parameter, so delete[] was being passed the size of a single element rather than the whole array. This was found in a comment on the reland PR.
The reason it took 11 years and 7 months is not "nobody worked on it." It's because the surface this change touches is wide.
The Two Things Clang 22 Changed
In Clang 22's release notes, "Potentially Breaking Changes", this story is split into two entries.
First, aligning the scalar deleting destructor (PR #139566, merged 2025-09-17). To carry over the release notes: clang previously implemented ::delete the way the Itanium ABI does — calling the complete object destructor and then calling the appropriate global delete operator — but now the scalar deleting destructor destroys the object and frees its storage as well.
How the PR author describes discovering this is entertaining. While working on the vector deleting destructor, they noticed that MSVC emits different code depending on whether the class defines its own operator delete. MSVC sets the 3rd bit of the flag when it's ::delete (5 for scalar, 7 for vector). Previous clang, by contrast, passed 0 as the flag and called the global delete directly at the call site. The problem shows up when linking against MSVC-compiled binaries — an MSVC-generated call site assumes "the destructor will call the global delete" and doesn't call it itself, but clang-generated destructor bodies never call it.
The release notes' example is clear. Say there's a class X that declares a virtual destructor and a member operator delete, with the destructor in library A and the ::delete call in library B. If A is built with clang 21 and B with clang 22, B's ::delete call dispatches to the scalar deleting destructor in A, and that incorrectly calls the member operator delete instead of the expected global delete. In the release notes' words, this is an ABI change that can cause memory corruption if part of a program built for the MSVC ABI is compiled with clang 21 or earlier and part with clang 22 (or MSVC).
Second, vector deleting destructor support. To carry over the release notes: the vtable of a class with a virtual destructor now holds a vector deleting destructor pointer instead of a scalar deleting destructor one, and that is a distinct symbol with a different name and different linkage. So it states that runtime failures can occur if two binaries that use the same class are compiled with different clang versions.
There's exactly one escape hatch. Both can be turned off with -fclang-abi-compat=21. Why one switch turns off both becomes clear from TargetInfo.cpp — the two hooks check exactly the same condition.
bool TargetInfo::callGlobalDeleteInDeletingDtor(
const LangOptions &LangOpts) const {
if (getCXXABI() == TargetCXXABI::Microsoft &&
LangOpts.getClangABICompat() > LangOptions::ClangABI::Ver21)
return true;
return false;
}
bool TargetInfo::emitVectorDeletingDtors(const LangOptions &LangOpts) const {
if (getCXXABI() == TargetCXXABI::Microsoft &&
LangOpts.getClangABICompat() > LangOptions::ClangABI::Ver21)
return true;
return false;
}
Notice the getCXXABI() == TargetCXXABI::Microsoft condition. These changes only turn on when targeting the MSVC ABI. Linux, macOS, and MinGW, which use the Itanium ABI, never enter this code path at all. This matches exactly the scope the issue reporter narrowed down experimentally.
After the Release — Two Bugs, One Backport
Within two days of 22.1.0 going out, two issues were filed. They are two separate bugs.
#183621 (reported 2026-02-26) — Clang 22 incorrect code for delete[] with MS ABI. The cause was devirtualization. The description of the fix, PR #183741, is concise — because the vector deleting destructor runs a loop over array elements and calls delete[], simply devirtualizing that call produces incorrect code that leaks memory. The fix was to check whether the call is devirtualizable before emitting a virtual call to the vector deleting destructor, and, if it is, emit a normal loop over array elements instead of a virtual call.
This was merged on March 5, 2026, and landed in release/22.x via backport PR #184806, shipping in 22.1.2 (2026-03-24). Diffing between llvmorg-22.1.1 and llvmorg-22.1.2 shows the commit right there.
#183255 (reported 2026-02-25) — the heap corruption from the top of this post. The maintainer's diagnosis goes like this. Because the destructor isn't exported, clang generates a scalar deleting destructor definition for impl.cpp — since that TU has no new[] call. But the delete[] in main.cpp expects a vector deleting destructor definition, and there isn't one there. The key observation that follows is that MSVC gets past this situation by generating a vector deleting destructor definition in main.cpp's object file — and only when new[] is called there — while clang generates no destructor definition at all for main.cpp.
A temporary workaround was offered alongside it — move the destructor definition into the header, or export it with dllexport.
The fix, PR #185653, "Define vector deleting dtor body for declared-only dtor if needed," was merged on March 17, 2026. The problematic condition is spelled out precisely in the PR description — currently, vector deleting destructor body generation is triggered when new[] is called for that type and the destructor or the whole class is marked with the dllexport attribute. The problem is that it is not generated when new[] is called, the destructor isn't exported, and the TU in question has only a declaration while the definition lives elsewhere. So the vector deleting destructor body ends up missing, and delete[] fails at runtime.
And this is the most practically important part of this post — this fix has not been backported to 22.x.
A maintainer comment left on the issue gives the reason — they had just merged the PR that fixes the issue, but weren't confident it was a small enough change to cherry-pick into 22.x, so they'd let it sit on main for a while first.
Checking it directly confirms exactly that. Of the 180 commits from llvmorg-22.1.0 to the tip of the release/22.x branch (2026-06-15), only one — the devirtualization fix — is destructor-related. The commit for #185653 is an ancestor of release/23.x, but it has diverged from release/22.x. As of July 16, 2026, the latest release is 22.1.8 (2026-06-16), and release/23.x was cut just yesterday (2026-07-15) and doesn't even have an rc tag yet.
In other words, every version from 22.1.0 through 22.1.8 breaks the heap in this pattern. It's fixed only in LLVM 23 (expected roughly in September).
Why It Links Fine but Breaks the Heap at Runtime
This is where the bug gets genuinely interesting. If ??_E doesn't exist, shouldn't the linker normally catch it as an unresolved symbol? And yet the link succeeds quietly and the heap breaks at runtime. Why?
The answer is in MicrosoftCXXABI.cpp.
if (GD.getDtorType() == Dtor_VectorDeleting &&
!getContext().classNeedsVectorDeletingDestructor(dtor->getParent())) {
// Create GlobalDecl object with the correct type for the scalar
// deleting destructor.
GlobalDecl ScalarDtorGD(dtor, Dtor_Deleting);
// Emit an alias from the vector deleting destructor to the scalar deleting
// destructor.
CGM.EmitDefinitionAsAlias(GD, ScalarDtorGD);
return;
}
When clang decides "this class doesn't really need a vector deleting destructor" — that is, when it hasn't seen new[] in that TU and there's no dllexport — it emits ??_E as an alias pointing to ??_G. That makes sense as an optimization: there's no reason to emit two copies of a loop-carrying body when arrays aren't even used.
But this is precisely why the link succeeds. The ??_E symbol does exist. Its body is just scalar.
And what's missing from that body is shown by CGClass.cpp.
if (DtorType == Dtor_Deleting || DtorType == Dtor_VectorDeleting) {
if (CXXStructorImplicitParamValue && DtorType == Dtor_VectorDeleting)
EmitConditionalArrayDtorCall(Dtor, *this, CXXStructorImplicitParamValue);
EmitConditionalArrayDtorCall, which emits the array branch, is only called when it's Dtor_VectorDeleting. Here's what that function does.
llvm::Value *CheckTheBitForArrayDestroy = CGF.Builder.CreateAnd(
ShouldDeleteCondition, llvm::ConstantInt::get(CondTy, 2));
llvm::Value *ShouldDestroyArray =
CGF.Builder.CreateIsNull(CheckTheBitForArrayDestroy);
CGF.Builder.CreateCondBr(ShouldDestroyArray, ScalarBB, VectorBB);
It ANDs the flag with 2 and branches into dtor.scalar or dtor.vector. That's the array bit we saw earlier.
Now the pieces fit together.
impl.cppemits the vtable and puts??_Ein the destructor slot. But since that TU has nonew[],??_Eis an alias pointing to??_G(the scalar body).- The
delete[]inmain.cppmakes a virtual call through that slot, setting the array bit (2) in the flag. - The body that gets reached is the scalar body — the branch that checks the array bit simply isn't in the code.
- So it frees the memory as a scalar, without accounting for the array cookie. The heap breaks.
There's no way for the linker to catch this. The symbol name matches, the signature matches, only the meaning is different. It's a contract that lives outside the type system — "this symbol's body handles the array bit" — that got broken, and that's not the kind of thing a linker can check. This is why it's actually fortunate that RtlValidateHeap screams first in Debug builds. In Release, it just crashes.
The fix in #185653 defers this decision. When it sees new[], it forces vector deleting destructor body generation even if the destructor is only declared. In the PR's words, this is safe to do because the vector deleting destructor has weak linkage — even if multiple TUs each emit a body, the linker folds them into one. This is the same behavior MSVC had all along.
So, What Should You Check Right Now
Let's honestly scope this down first.
No impact. Itanium ABI targets like Linux, macOS, and MinGW aren't affected. The code path is guarded by TargetCXXABI::Microsoft. If you don't use clang-cl or -target *-windows-msvc, this post is just trivia.
Worth checking. If you're using, or planning to use, 22.x with clang targeting the MSVC ABI (clang-cl included):
- Are you mixing in objects/libraries built with clang 21 or earlier? If a class with a virtual destructor crosses that boundary, you land exactly in the memory-corruption scenario the release notes describe. Either rebuild everything, or, if you can't, pin the 22 side to the old behavior with
-fclang-abi-compat=21. - Are you
new[]/delete[]-ing a class with a virtual destructor whose destructor definition lives in a different TU and isn'tdllexport? This is the #183255 pattern, and it's still alive as of 22.1.8. The workaround is to move the destructor definition into the header or mark itdllexport. - If you're on 22.1.0 or 22.1.1, upgrade to at least 22.1.2. The devirtualization bug (#183621) fix is in there.
An honest word on -fclang-abi-compat=21. This is an undo button, not a solution. Turning this flag on makes you compatible with clang 21 and earlier, but it puts you out of sync with MSVC again — right back to the very mismatch this change was meant to fix. If you have plans to link against MSVC-built libraries, this isn't the answer. It's only really useful for postponing the transition inside a closed world where everything is built with clang.
This change itself is correct. Since this post is filled with bug stories, let me add — worried it might mislead — that Clang 22's direction is right. Previous clang emitted code that diverged from the vftables MSVC produced, and delete[] on an array of polymorphic objects would silently leak or call the wrong destructor. Fixing an 11-year-old incompatibility required an ABI change, and ABI changes naturally come with this kind of cost. The lesson of this story isn't "don't use clang 22" — it's that a change that crosses an ABI boundary fails in a place the linker can't help you, the moment you mix compiler versions.
Closing
To sum up: Clang 22.1.0 (2026-02-24) changed destructor codegen for the MSVC ABI target in two places — it aligned ::delete handling with MSVC by moving it inside the destructor body, and it added the vector deleting destructor, closing an issue opened in 2014 after 11 years and 7 months. It's an ABI change that swaps the vftable destructor slot's symbol from ??_G to ??_E, and the one escape hatch is -fclang-abi-compat=21.
And within two days starting the day after release, two bugs surfaced. The devirtualization bug was backported into 22.1.2. But the one where ??_E goes out as an alias for ??_G and breaks the heap without the branch that handles the array bit — as of July 16, 2026, that one still hasn't been fixed even in 22.1.8. You either wait for LLVM 23, or move the destructor into the header, or mark it dllexport.
Looking at why this feature landed four times and got reverted three — reverted twice for breaking Chromium (a sanitizer coverage link error, and then /Zc:DllexportInlines-), reverted once because looking up operator delete[] was a security hazard, and fixed for passing the wrong array size five days after the final reland — makes it clear that the 11 years weren't laziness. A compiler's ABI surface can be summed up in one line of release notes, but the failure modes that one line covers only surface once the release ships and users run their own codebases into it. This time, it took a day.
References
- Clang 22 release notes — Potentially Breaking Changes (release/22.x source)
- Issue #19772 — Implement vector deleting destructors (opened 2014-04-11)
- Issue #183255 — [Clang-Cl] Wrong destructing code generated in Clang 22
- Issue #183621 — [clang] Clang 22 incorrect code for
delete[]with MS ABI - PR #139566 — [win][clang] Align scalar deleting destructors with MSABI
- PR #170337 — Reland [MS][clang] Add support for vector deleting destructors
- PR #133451 — Reland: explains the first revert was a Chromium sanitizer coverage link error
- PR #135611 — Revert: operator delete[] lookup was a security hazard
- PR #169116 — the third revert that actually got merged
- PR #169063 — the unmerged revert PR: records the Chromium /Zc:DllexportInlines- build breakage
- PR #172513 — fixes the size passed to delete[]
- PR #183741 — devirtualization fix (backported to 22.1.2)
- PR #185653 — generates the vector deleting dtor body for declaration-only destructors (not backported to 22.x)
- MicrosoftCXXABI.cpp (release/22.x) — flag bits and alias generation
- CGClass.cpp (release/22.x) — EmitConditionalArrayDtorCall
- TargetInfo.cpp (release/22.x) — the abi-compat gate
- LLVM release list (22.1.0 = 2026-02-24, 22.1.8 = 2026-06-16)
현재 단락 (1/151)
LLVM/Clang 22.1.0 was [released on February 24, 2026](https://github.com/llvm/llvm-project/releases)...