Summary
rfl::json::read<T>() segfaults with GCC 12 at -O2 for certain structs. The generated field view holds garbage pointers, and NamedTupleParser::handle_one_missing_field then placement-news through one of them.
Parser_default::read_struct allocates a raw byte buffer, reinterprets it as T*, and builds the view by binding to members of an object whose lifetime never began:
https://github.com/getml/reflect-cpp/blob/main/include/rfl/parsing/Parser_default.hpp#L684-L687
alignas(T) unsigned char buf[sizeof(T)]{};
auto ptr = internal::ptr_cast<T*>(&buf);
auto view = ProcessorsType::template process<T>(to_view(*ptr));
Compiling that translation unit with -fno-strict-aliasing makes the crash go away, which points at TBAA acting on the unsigned char / T type pun.
This is still present on main — the three lines above are unchanged since at least 0.18.0.
Reproducer
Self-contained; needs no input file.
#include <rfl.hpp>
#include <rfl/json.hpp>
#include <cstdint>
#include <iostream>
#include <optional>
namespace { // internal linkage is required to trigger it
struct Inner {
std::optional<std::uint32_t> a;
std::optional<int> b;
};
struct Outer {
std::optional<std::uint32_t> f1;
std::optional<std::uint32_t> f2;
std::optional<std::uint32_t> f3;
std::optional<std::uint32_t> f4;
std::optional<std::uint32_t> f5;
std::optional<std::uint32_t> f6;
std::optional<std::uint32_t> f7;
std::optional<std::uint32_t> f8;
std::optional<std::uint32_t> f9;
std::optional<std::uint32_t> f10;
std::optional<std::uint32_t> f11;
std::optional<Inner> inner; // an optional nested aggregate is required to trigger it
};
} // namespace
int main() {
const auto res = rfl::json::read<Outer>("{}");
std::cout << (res ? "OK" : "parse error") << std::endl;
}
$ R=/path/to/reflect-cpp-0.25.0
$ gcc-12 -O2 -I "$R/include/rfl/thirdparty" -c "$R/src/yyjson.c" -o yyjson.o
$ g++-12 -std=c++20 -O2 -I "$R/include" \
repro.cpp "$R/src/reflectcpp.cpp" "$R/src/reflectcpp_json.cpp" yyjson.o -o repro
$ ./repro
Segmentation fault (core dumped)
Expected OK — every field is optional and the input is {}.
Backtrace
Program received signal SIGSEGV, Segmentation fault.
NamedTupleParser<...>::handle_one_missing_field<0> (...)
at include/rfl/parsing/NamedTupleParser.hpp:305
305 ::new (rfl::get<_i>(_view)) ValueType();
#4 Parser<..., (anonymous namespace)::Outer, ...>::read_struct (...)
at include/rfl/parsing/Parser_default.hpp:247
#5 Parser<..., (anonymous namespace)::Outer, ...>::read (...)
at include/rfl/parsing/Parser_default.hpp:94
#6 rfl::json::read<(anonymous namespace)::Outer> (...) at include/rfl/json/read.hpp:59
#7 main () at repro.cpp:39
The view's stored field pointers are already garbage on entry — the first one is 0xfffffffffffffff8:
_view = {values_ = {static size_ = 12, static num_bytes_ = 96,
data_ = {_M_elems = "\370\377\377\377\377\377\377\377..."}}}
Field reflection itself is fine: rfl::internal::num_fields<Outer> is 12 and rfl::fields<Outer>() reports the correct names and types at every -O level. Only the parse is miscompiled.
What does and does not trigger it
Everything below is the reproducer above, varied one axis at a time, reflect-cpp 0.25.0.
| variation |
result |
g++-12 -O2 |
SIGSEGV |
g++-12 -O0 / -O1 / -O3 |
OK |
g++-12 -O2 -fno-strict-aliasing |
OK |
g++-11 (11.5.0) -O2 |
OK |
g++-14 (14.3.0) -O2 |
OK |
| struct at namespace scope instead of an anonymous namespace |
OK |
std::optional<Inner> inner replaced by a 12th std::optional<std::uint32_t> |
OK |
fewer fields (11 or below, keeping inner) |
OK |
rfl::json::read<Outer, rfl::DefaultIfMissing> (uses read_struct_with_default, a real T{}) |
OK |
So it needs all three of: internal linkage, an std::optional<NestedAggregate> member, and enough fields. The field threshold is version-dependent — on 0.18.0 the same reproducer crashes from 10 fields, on 0.25.0 from 12 — which is what you would expect from an inlining/aliasing heuristic rather than a fixed limit.
The DefaultIfMissing row is the interesting contrast: that path goes through read_struct_with_default, which builds the view over a properly constructed auto t = T{} instead of a reinterpreted byte buffer, and it does not crash.
Note on ptr_cast
include/rfl/internal/ptr_cast.hpp says:
Normally, we would use std::launder(reinterpret_cast<...>(...)), but there are weird issues on GCC 12 under certain compiler settings, so we are using this workaround instead.
That looks like the same GCC 12 behaviour surfacing elsewhere. Dropping std::launder removes the diagnostic but leaves the underlying problem: no T object's lifetime has begun in buf, so binding the view to *ptr's members is UB regardless of how the pointer is formed.
I also tried marking the cast target __attribute__((__may_alias__)), which did not help — consistent with the corruption being in constructing the view rather than in later stores through it.
Environment
- reflect-cpp 0.25.0 (also reproduced on 0.18.0;
read_struct is byte-identical on main)
- g++ 12.5.0, x86_64 Linux,
-std=c++20
- Not reproducible on g++ 11.5.0 or 14.3.0
Workaround
Building the translation unit that instantiates rfl::json::read with -fno-strict-aliasing avoids it. The flag is per-TU and survives LTO even when the link line omits it.
Summary
rfl::json::read<T>()segfaults with GCC 12 at-O2for certain structs. The generated field view holds garbage pointers, andNamedTupleParser::handle_one_missing_fieldthen placement-news through one of them.Parser_default::read_structallocates a raw byte buffer, reinterprets it asT*, and builds the view by binding to members of an object whose lifetime never began:https://github.com/getml/reflect-cpp/blob/main/include/rfl/parsing/Parser_default.hpp#L684-L687
Compiling that translation unit with
-fno-strict-aliasingmakes the crash go away, which points at TBAA acting on theunsigned char/Ttype pun.This is still present on
main— the three lines above are unchanged since at least 0.18.0.Reproducer
Self-contained; needs no input file.
Expected
OK— every field is optional and the input is{}.Backtrace
The view's stored field pointers are already garbage on entry — the first one is
0xfffffffffffffff8:Field reflection itself is fine:
rfl::internal::num_fields<Outer>is 12 andrfl::fields<Outer>()reports the correct names and types at every-Olevel. Only the parse is miscompiled.What does and does not trigger it
Everything below is the reproducer above, varied one axis at a time, reflect-cpp 0.25.0.
g++-12 -O2g++-12 -O0/-O1/-O3g++-12 -O2 -fno-strict-aliasingg++-11(11.5.0)-O2g++-14(14.3.0)-O2std::optional<Inner> innerreplaced by a 12thstd::optional<std::uint32_t>inner)rfl::json::read<Outer, rfl::DefaultIfMissing>(usesread_struct_with_default, a realT{})So it needs all three of: internal linkage, an
std::optional<NestedAggregate>member, and enough fields. The field threshold is version-dependent — on 0.18.0 the same reproducer crashes from 10 fields, on 0.25.0 from 12 — which is what you would expect from an inlining/aliasing heuristic rather than a fixed limit.The
DefaultIfMissingrow is the interesting contrast: that path goes throughread_struct_with_default, which builds the view over a properly constructedauto t = T{}instead of a reinterpreted byte buffer, and it does not crash.Note on
ptr_castinclude/rfl/internal/ptr_cast.hppsays:That looks like the same GCC 12 behaviour surfacing elsewhere. Dropping
std::launderremoves the diagnostic but leaves the underlying problem: noTobject's lifetime has begun inbuf, so binding the view to*ptr's members is UB regardless of how the pointer is formed.I also tried marking the cast target
__attribute__((__may_alias__)), which did not help — consistent with the corruption being in constructing the view rather than in later stores through it.Environment
read_structis byte-identical onmain)-std=c++20Workaround
Building the translation unit that instantiates
rfl::json::readwith-fno-strict-aliasingavoids it. The flag is per-TU and survives LTO even when the link line omits it.