Building clang-p2996 for C++26 Reflection — Three Things That Went Wrong
C++26 reflection (P2996) is real and you can touch it today. Bloomberg maintains a clang fork, clang-p2996, that implements the proposal. There’s no ready-made binary — you build it yourself, and the path from “fresh WSL2” to a working ^^int is littered with small traps.
This is the log of that walk. Nothing surprising if you’ve built LLVM before, but three specific things broke in ways the README doesn’t warn about.
The target
One clang++ binary that compiles this:
#include<meta>
#include<iostream>
struct Point { int x; int y; };
int main() {
template for (constexpr auto m :
std::define_static_array(
std::meta::nonstatic_data_members_of(
^^Point, std::meta::access_context::unchecked())))
{
std::cout << std::meta::identifier_of(m) << '\n';
}
}It prints x\ny. That’s it. Every piece of the validator project I’m about to build rides on this exact pipeline working.
Environment
- Windows 11, WSL2 Ubuntu 24.04.4
- 12 cores, 7.7 GB RAM, plenty of disk
- Host GCC 13.3 for bootstrapping The 7.7 GB of RAM is the single biggest constraint. It decides everything that follows.
Step 1 — Dependencies
sudo apt update && sudo apt install -y \
build-essential cmake ninja-build lld git python3 python3-devlld matters. The default linker on Ubuntu is ld.bfd, which eats 4–6 GB linking clang. With 7.7 GB total, that’s a coin flip against OOM. lld stays under 3 GB and is a drop-in replacement via -DLLVM_USE_LINKER=lld.
Step 2 — Clone
mkdir -p ~/src && cd ~/src
git clone --depth=1 --branch p2996 https://github.com/bloomberg/clang-p2996.gitShallow clone is 2.5 GB. The p2996 branch is the experimental one — the default branch tracks upstream LLVM, which doesn’t have reflection.
Step 3 — Configure
cd ~/src/clang-p2996
cmake -S llvm -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DLLVM_ENABLE_PROJECTS=clang \
-DLLVM_USE_LINKER=lld \
-DLLVM_PARALLEL_LINK_JOBS=1 \
-DLLVM_PARALLEL_COMPILE_JOBS=8 \
-DLLVM_TARGETS_TO_BUILD=X86 \
-DLLVM_ENABLE_ASSERTIONS=OFF \
-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++The three flags that matter on a 7.7 GB box:
LLVM_PARALLEL_LINK_JOBS=1— never link in parallel. One linker is already 2–3 GB. Two is OOM territory.LLVM_PARALLEL_COMPILE_JOBS=8— 12 cores, but clang compile units peak around 1 GB each. Twelve in flight wouldn’t fit either.LLVM_TARGETS_TO_BUILD=X86— we only need x86_64. Default builds AArch64, ARM, MIPS, etc., which triples compile time and produces nothing we’ll use.
Step 4 — Build clang, discover the first trap
ninja -C build clangFirst mistake: running this through wsl -d Ubuntu -- bash -c "..." from a remote agent. When the outer bash exits, WSL kills the process tree. nohup + disown did not save it. The build got stopped at step 3/2534.
Second mistake: piping through tail -c 4000 to filter output. tail buffers until EOF, so the output file stays empty the entire build. No way to tell if it’s progressing.
What actually worked was a wrapper script with tee + awk filter, run under a persistent background watcher:
#!/bin/bash
cd ~/src/clang-p2996
rm -f build.log build.done
set -o pipefail
ninja -C build clang 2>&1 | tee build.log | stdbuf -oL awk '
/FAILED|^error:|ninja: error|Killed|OOM|Linking CXX.*\/clang$|build stopped/ { print; fflush() }
'
exit_code=${PIPESTATUS[0]}
echo "$exit_code" > build.done
echo "BUILD_EXIT=$exit_code"stdbuf -oL forces line buffering so the awk filter sees lines as they arrive. PIPESTATUS[0] preserves ninja’s exit code through the pipeline. Everything interesting — errors, OOM, the final link — surfaces immediately. Everything else goes silently into build.log for post-mortem.
Total build: ~25 minutes on this hardware, target clang only (not the full all target). Result: build/bin/clang-21, 148 MB.
$ ./build/bin/clang --version
clang version 21.0.0git (https://github.com/bloomberg/clang-p2996.git 9ffb96e...)
Target: x86_64-unknown-linux-gnu
Step 5 — First smoke test, and the consteval trap
With a freshly-minted clang-21, the minimum sanity check:
int main() {
constexpr auto r = ^^int;
(void)r;
return 0;
}error: expressions of consteval-only type are only allowed in constant-evaluated contexts
(void)r;
^
This is actually a good error. It means:
^^intparsed.- It evaluated to a
std::meta::infovalue. - The compiler correctly flagged
std::meta::infoas consteval-only — you cannot hold a reflection at runtime. The fix is to keep everything at compile time:
consteval bool reflect_check() {
auto r = ^^int;
return r == ^^int;
}
static_assert(reflect_check());
int main() { return 0; }Compiles, runs, exits 0. Reflection operator confirmed. Now the second trap.
Step 6 — <meta> doesn’t exist (yet)
The validator needs std::meta::nonstatic_data_members_of. That lives in <meta> — a libc++ header.
My build enabled LLVM_ENABLE_PROJECTS=clang. Not LLVM_ENABLE_RUNTIMES=libcxx. So clang is built, but its standard library is whatever the host GCC 13 ships. GCC’s libstdc++ does not have <meta>. Nothing does, except the libc++ in clang-p2996/libcxx/ — and those are source headers, not installed.
Reconfigure:
cmake -S llvm -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DLLVM_ENABLE_PROJECTS=clang \
-DLLVM_ENABLE_RUNTIMES="libcxx;libcxxabi;libunwind" \
... (rest unchanged)
ninja -C build cxx cxxabi unwindAnother ~10 minutes. You end up with:
- Headers in two places:
build/include/c++/v1/(standard) andbuild/include/x86_64-unknown-linux-gnu/c++/v1/(target-specific, contains__config_sitewhich<__config>requires) - Libraries in
build/lib/x86_64-unknown-linux-gnu/Both include paths are needed. Miss either and you get'__config_site' file not foundbefore your code is ever looked at.
Step 7 — template for hates heap vectors
First attempt at the real smoke test:
template for (constexpr auto member :
std::meta::nonstatic_data_members_of(
^^Point, std::meta::access_context::unchecked()))
{
std::cout << std::meta::identifier_of(member) << '\n';
}error: constexpr variable '__range' must be initialized by a constant expression
note: pointer to subobject of heap-allocated object is not a constant expression
note: heap allocation performed here
return {allocate(__n), __n};
nonstatic_data_members_of returns a std::vector<std::meta::info>. C++26 allows transient allocation during constant evaluation, but the result — heap pointers — cannot escape into a runtime constexpr object. template for needs exactly that escape.
P3491 (define_static_{string,object,array}) solves this. It copies a transient value into static storage so you can hold a stable reference to it:
template for (constexpr auto member :
std::define_static_array(
std::meta::nonstatic_data_members_of(
^^Point, std::meta::access_context::unchecked())))Two subtleties worth pinning down:
define_static_arrayis instd, notstd::meta. The compiler’s “did you mean” hint told me that one.nonstatic_data_members_oftakes two arguments — type andaccess_context. The one-argument form in older P2996 drafts is gone.access_context::unchecked()is fine for a learning project;::current()respects C++ access control. Both of these contradicted my mental model from reading the P2996 paper a few months ago. The Bloomberg fork is ahead of the paper.
The working command
After two days of rebuilds and five wrong compile lines, this is the invocation that works for every file in the project going forward:
/home/user/src/clang-p2996/build/bin/clang++ \
-std=c++26 -freflection-latest -stdlib=libc++ -nostdinc++ \
-isystem /home/user/src/clang-p2996/build/include/x86_64-unknown-linux-gnu/c++/v1 \
-isystem /home/user/src/clang-p2996/build/include/c++/v1 \
-L /home/user/src/clang-p2996/build/lib/x86_64-unknown-linux-gnu \
-Wl,-rpath,/home/user/src/clang-p2996/build/lib/x86_64-unknown-linux-gnu \
-o out src.cppnostdinc++blocks the host libstdc++ from sneaking in and confusing includes.Wl,-rpathlets the linked binary findlibc++.so.1withoutLD_LIBRARY_PATHgymnastics. Run the final smoke test and you get:
x
y
Two lines. Reflected out of a struct, iterated at compile time, printed at runtime.
Takeaways
The three things that actually cost time, in order of annoyance:
- The P2996 paper is already stale against the reference implementation. Always grep the clang-p2996 test directory (
libcxx/test/std/experimental/reflection/) for the current signature before trusting the proposal document. - libc++ is not optional for reflection.
LLVM_ENABLE_RUNTIMESis easy to forget if you read the LLVM “getting started” page as a template and think “I just need clang.” template for** only iterates static ranges**, andnonstatic_data_members_ofreturns a heap vector.std::define_static_arrayis the bridge. Without it, you get a wall ofnot a constant expressionnotes that never point at the actual problem. Next: using this setup to build a declarative validator —[[=Range{0, 150}]] int age;and friends. The reflection just happens to be the boring part now.