llvm / llvm/llvm-project

[aarch64] miscompile: store/load reordered across union members of distinct types when the throwing store is inlined

Open
#196,520 9 comments 0 reactions 0 assignees View on GitHub
miscompilation needs-reduction TBAA
Dominant language
LLVM
Stars
40.5k
Forks
18.7k
PR merge metrics
PR metrics pending

Description

Hi, I am a maintainer of [libfn/functional](https://github.com/libfn/functional) and, while working on a [code cleanup](https://github.com/libfn/functional/pull/189) for a polyfill for `std::expected` intended for C++20 compilers, I stumbled upon a bug in LLVM which broke my unit tests running on aarch64 - [example CI breakage here](https://github.com/libfn/functional/actions/runs/25520696952/job/74903641478?pr=189). The **bug is specific to aarch64**; I cannot reproduce it on x86-64 architecture.

I asked Claude Opus 4.7 to help me diagnose the problem, and this is what it produced. Hope it's useful !

## Summary

On aarch64-linux clang at `-O1` and above produces wrong code for a function that takes two reference parameters of *distinct* types, when both references at runtime designate (different members of) the **same union**.

After inlining, a store performed through one reference is hoisted past a load performed through the other, even though the two pointers reach the same byte of storage. The load returns the value just written, instead of the prior contents of the storage.

In the reproducer the function is the [canonical strong-exception-guarantee helper](https://eel.is/c++draft/expected.object.assign) used by `std::expected` / `std::variant` / any tagged-union type:

```cpp
template
void reinit(New& newval, Old& oldval, A&&... args) {
Old tmp(std::move(oldval)); // (a) sample *oldval
std::destroy_at(std::addressof(oldval)); // (b) end old lifetime
try {
std::construct_at(std::addressof(newval), // (c) start new lifetime
std::forward(args)...); // -- may throw
} catch (...) {
std::construct_at(std::addressof(oldval), // (d) restore from tmp
std::move(tmp));
throw;
}
}
```

When (c) is inlined, its first store (writing the new value through `*newval`) is reordered ahead of (a)'s load of `*oldval`, so `tmp` ends up holding the freshly-written byte pattern instead of the original. If (c) then throws, (d) restores the corrupted value. Result in the reproducer: the original error `5` is replaced by `0`.

The bug is independent of `std::expected` — it is a property of the codegen for the (reference-of-T, reference-of-U) inlining pattern under TBAA. See "Code patterns affected" below.

## Reproduction

Standalone source file `.scratch/aarch64_eh_repro.cpp`

```c++
// Minimal repro for an aarch64 + clang-22 -O3 codegen/EH miscompile that
// affects pfn::expected (libfn/functional). The shape mirrors the strong-
// exception-guarantee path of std::expected::operator=(U&&) when the
// expected currently holds the error and the value's constructor throws
// (libc++ calls this `__reinit_expected`; the project calls it `_reinit`).
//
// Build / run:
// clang++-22 -std=c++23 -O3 -DNDEBUG -fasynchronous-unwind-tables \
// .scratch/aarch64_eh_repro.cpp -o .scratch/repro && .scratch/repro
//
// Pass: prints "e_=5 (want 5)" and exits 0.
// Fail: either prints "e_=0 (want 5)" (exit 1) or SIGSEGVs during unwind.
//
// Reproduces with both libstdc++ and libc++. Reproduces with several
// equivalent source shapes (try/catch, RAII guard, placement-new). Does
// NOT reproduce on x86_64 with the same flags. Does NOT reproduce at -O0.

#include
#include
#include
#include

enum Err : int { file_not_found = 5 };

// Models helper_t<33> from the project's tests/util/helper_types.hpp:
// - non-noexcept move ctor
// - mutates global state before throwing, so reads are observable
struct G {
int v;
static inline int state = 0;
G(int x) noexcept : v(x) { state += v; }
G(G &&o) : v(o.v) { // intentionally NOT noexcept
state += v;
if (v == 0)
throw std::runtime_error("invalid input");
}
~G() = default;
};

template struct expected_like {
union {
T v_;
E e_;
};
bool set_;

explicit expected_like(E e) : e_(std::move(e)), set_(false) {}
~expected_like() {
if (set_)
std::destroy_at(std::addressof(v_));
else
std::destroy_at(std::addressof(e_));
}

// Direct mirror of pfn::expected::_reinit's else-branch.
template
static void reinit(New &newval, Old &oldval, A &&...args) {
Old tmp(std::move(oldval));
std::destroy_at(std::addressof(oldval));
try {
std::construct_at(std::addressof(newval), std::forward
(args)...);
} catch (...) {
std::construct_at(std::addressof(oldval), std::move(tmp));
throw;
}
}

void assign(T &&s) {
// Mimics operator=(U&&) when *this holds the error.
reinit(v_, e_, std::move(s));
set_ = true; // not reached if reinit throws
}
};

int main() {
expected_like a{Err::file_not_found}; // a.e_ == 5
G tmp(0);
try {
a.assign(std::move(tmp)); // throws inside construct_at(&v_)
std::puts("FAIL: did not throw");
return 2;
} catch (std::runtime_error const &) {
std::printf("e_=%d (want %d)\n", (int)a.e_, (int)Err::file_not_found);
return a.e_ == Err::file_not_found ? 0 : 1;
}
}
```

Build / run:
```
clang++-22 -std=c++23 -O3 -DNDEBUG -fasynchronous-unwind-tables \
.scratch/aarch64_eh_repro.cpp -o .scratch/repro && .scratch/repro
```

Expected: `e_=5 (want 5)` (rc 0). Observed: `e_=0 (want 5)` (rc 1).

## Compiler / flag matrix (aarch64-linux clang 22.1.6)

| Flags | libstdc++ | libc++ |
|---------------------------------|-----------|--------|
| `-O0` | PASS | PASS |
| `-O1` | **FAIL** | **FAIL** |
| `-O2` | **FAIL** | **FAIL** |
| `-O3` | **FAIL** | **FAIL** |
| `-Og` | **FAIL** | **FAIL** |
| `-Os` | PASS | PASS |
| `-O3 -fno-strict-aliasing` | PASS | PASS |
| `-O3 -fno-inline` | PASS | PASS |
| `-O3 -fno-inline-functions` | PASS | PASS |

Stdlib has no influence. **GCC 16.1.0 on the same target passes at every optimization level.**

`-fno-strict-aliasing` and `-fno-inline` each independently mask the bug, strongly suggesting a TBAA assumption (`G*` and `Err*` cannot alias) that goes wrong once the throwing constructor is inlined into the helper.

## Cross-version data (not a recent regression)

The same downstream test that triggered this investigation (`pfn::expected`, see "Where it was first observed") fails in CI under **clang 20, clang 21, and clang 22** on aarch64-linux Release builds; it passes on the same compilers/configurations on x86_64. We have not bisected to a specific LLVM commit, but the bug is **at least three major releases old**, so any fix will likely need backporting to the active release branches.

## Code patterns affected

The reproducer's structure — a function with two reference parameters of distinct types whose runtime referents share storage — is the canonical shape of every C++ tagged-union active-member transition. Concretely the following implementations contain code matching this shape that *will* be miscompiled if a sufficiently-shaped throwing constructor is inlined into the transition helper:

* `std::expected` value↔error transitions (libstdc++, libc++, MSVC STL, all conforming third-party impls).
* `std::variant<...>` alternative transitions when the new alternative's constructor can throw (the strong-EG path through a temporary).
* Any user-written tagged union / sum type implementing the same idiom.

We have not built a `libstdc++`/`libc++` `std::expected`-only reproducer because the inlined IR shape, not the source, is what triggers the bug; the standalone `expected_like` in the reproducer reaches the same shape with no library dependencies and no UB (see Sanitizer/valgrind section).

## Where it was first observed

`pfn::expected` (https://github.com/libfn/functional, header tracking the C++ working draft) fails its strong-exception-guarantee unit tests on aarch64 Release builds, commit [a853d19](https://github.com/libfn/functional/pull/189/changes/a853d1980dfbabfbdf75af2ca67dfb6617ff9508):
```
tests/pfn/expected.cpp:1426: FAILED:
CHECK( a.error() == Error::file_not_found )
with expansion: 0 == 5
```
The relevant PR's CI shows the same failure under clang 20/21/22.

## The smoking gun (asm)

Run
```
clang++-22 -std=c++23 -O3 -DNDEBUG -fasynchronous-unwind-tables \
-S .scratch/aarch64_eh_repro.cpp -o .scratch/repro_O3.s
```

In `.scratch/repro_O3.s`, the helper function

`expected_like::reinit(G& newval, Err& oldval, G&& args)`

is called from `main` with `x0 == x1 == &storage` (the union, both references designate it) and `x2 == &args`. `G` is the throwing type that's being constructed; `Err` is `enum : int` that holds the live value:

```asm
ldr w8, [x2] // 1. w8 = args.v (=0)
adrp x9, _ZN1G5stateE
str w8, [x0] // 2. *newval = w8 <-- inlined G(G&&) body
ldr w10, [x9, :lo12:_ZN1G5stateE] // state += v ...
add w8, w10, w8
str w8, [x9, :lo12:_ZN1G5stateE]
ldr w8, [x1] // 3. w8 = *oldval <-- READS WHAT WE JUST WROTE!
ldr w9, [x0] // 4. w9 = *newval (=0)
cbz w9, .LBB2_2 // 5. v == 0 -> throw
...
.LBB2_2: // throw path: spill w8 ("the saved old value")
stur w8, [x29, #-4]
str x1, [sp]
...
.LBB2_7: // landing pad / catch
bl __cxa_begin_catch
ldur w8, [x29, #-4] // reload "saved" old value (= 0, corrupted)
ldr x9, [sp]
str w8, [x9] // *oldval = 0 -- BUG
bl __cxa_rethrow
```

Source order calls for: (a) sample `*oldval` into local `tmp`, (b) `destroy_at(oldval)` (no-op for trivial `Err`), (c) `construct_at(newval, …)` (which writes through `*newval`, then may throw), (d) on throw, restore `*oldval` from the previously-sampled `tmp`. Because `tmp` for a trivial type is dead-code-eliminated and replaced by a re-load of `*oldval`, and because TBAA tells the optimizer that `G*` and `Err*` cannot alias, the re-load is sunk past the store through `*newval`. The two pointers in fact designate the same byte of storage at the call site (different members of the same union), so the re-load returns the just-written value. The catch handler then "restores" the corrupted value via `*oldval`.

## Sanitizer / valgrind results (per LLVM submit-a-bug guide)

All run on the same `.scratch/aarch64_eh_repro.cpp`, clang 22.1.6:

| Build | rc | Sanitizer diagnostic | Output |
|------------------------------------------------|-----|----------------------|-------------------|
| `-O3` (no instrumentation, baseline) | 1 | n/a | `e_=0 (want 5)` |
| `-O3 -fsanitize=undefined` | 0 | none | `e_=5 (want 5)` |
| `-O3 -fsanitize=undefined,integer,nullability,implicit-conversion -fno-sanitize-recover=all` | 0 | none | `e_=5 (want 5)` |
| `-O3 -fsanitize=address` | 0 | none | `e_=5 (want 5)` |
| `-O3 -fsanitize=address,undefined` | 0 | none | `e_=5 (want 5)` |
| `-O3 -fsanitize=hwaddress` (aarch64-native) | 0 | none | `e_=5 (want 5)` |
| `-O3 -fsanitize=memory -fsanitize-memory-track-origins=2` | 0 | none | `e_=5 (want 5)` |
| `-O3 -fsanitize=thread` | 0 | none | `e_=5 (want 5)` |
| `-O3` under `valgrind --tool=memcheck` | 1 | none | `e_=0 (want 5)` |
| `-O0` under `valgrind --tool=memcheck` | 0 | none | `e_=5 (want 5)` |

Two things worth flagging for upstream:

1. **No sanitizer or valgrind reports any UB or memory error** — the reproducer is clean. The guarantee that this is a codegen miscompile, not a source-level UB on our part, is as strong as the standard instrumentation can provide.
2. **Every form of clang instrumentation masks the bug** (every sanitized binary returns the correct `e_=5`). Valgrind, which does not change codegen, still reproduces. This is the expected fingerprint of a middle-/back-end optimization issue gated by IR shape — same as `-fno-strict-aliasing` and `-fno-inline` masking it (see flag matrix).

## Environment

* Host: aarch64 Linux, kernel 6.12.85.
* Local compiler: `Debian clang version 22.1.6 (++20260506084000+60e3203b2e90-1~exp1~20260506084018.71)` built off llvm-project commit `60e3203b2e90`.
* libc++/libc++abi: 22.1.6 (reproduces).
* libstdc++ (GCC 14.2.0): also reproduces.
* GCC 16.1.0 on the same machine: passes at every opt level.
* `-target-feature +outline-atomics` is on by default for this target; not investigated as a factor.
* CI evidence (clang 20, 21, 22 on aarch64-linux Release): all fail; same configurations on x86_64: all pass. We have not bisected to a specific LLVM commit.

Contributor guide

Open the contributing guide

Research direction

Start by building and running .scratch/aarch64_eh_repro.cpp with the provided clang++-22 aarch64 -O3 command, then inspect .scratch/repro_O3.s and the listed helper entry point. Compare optimized behavior with -O0, -fno-inline, and -fno-strict-aliasing while tracing the inlining and TBAA interaction. Done means the reproducer preserves e_=5 on aarch64 optimized builds and the regression is covered by an LLVM test.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp
Domain
compilers
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.