Skip to content

Keep expected trivially copyable under clang when no copy assignment is eligible - #100

Merged
steve-downey merged 2 commits into
bemanproject:mainfrom
steve-downey:clang-libstdcxx-triviality
Oct 18, 2026
Merged

steve-downey merged 2 commits into
bemanproject:mainfrom
steve-downey:clang-libstdcxx-triviality

Conversation

@steve-downey

Copy link
Copy Markdown
Member

Every clang job built against libstdc++ fails beman::expected type traits match std::expected on main, for clang 19 through 22 and every C++ mode. The gcc jobs and the clang jobs on libc++ pass. The failing check is is_trivially_copyable for expected<int, MoveOnly> and expected<void, MoveOnly> (and the same for NoexceptMoveOnly).

Cause

Two things combine.

  • libstdc++ 16 made std::expected<int, MoveOnly> trivially copyable. The test checks "beman is at least as trivial as std", which is an implication, so until the CI images moved to libstdc++ 16 there was nothing to check.

  • Clang gets beman::expected<int, MoveOnly> wrong. For a move-only E, neither the trivial nor the non-trivial copy assignment is satisfied. [class.prop] counts only eligible special members, so the class is trivially copyable, and gcc agrees. Clang (17 through at least 24) treats a class whose copy-assignment candidates all have unsatisfied constraints as not trivially copyable:

    template <bool B> struct C {
        C(const C&) = delete;
        C(C&&) = default;
        C& operator=(const C&) requires B = default;
        C& operator=(C&&) = default;
    };
    static_assert(__is_trivially_copyable(C<false>));  // gcc: passes; clang: fails

Fix

The primary template, expected<void, E> and expected<T&, E> each get a deleted copy assignment that is satisfied exactly when neither existing path is. A deleted candidate is not eligible either, so the triviality it restores is the one the standard gives, and is_copy_assignable is unchanged. Move assignment is left alone: a satisfied deleted move assignment would stop an rvalue falling back to copy assignment for an E with a deleted move.

The new overload is \omitted from the generated wording, and its rationale sits after the primary template rather than on the declaration, because a comment on an omitted member is still carried into the synopsis.

The equivalence test's skip for Immovable was covering the same clang bug, so it is removed. expected_trivial.test.cpp gains a direct check that expected<int|void|int&, MoveOnly> is trivially copyable and not copy-assignable; unlike the equivalence test, it also runs on libc++.

Toolchains

The local clang-N toolchains all use libc++, where the equivalence test is skipped, so this failure could not be reproduced with them. This adds clang-22-libstdc++ and clang-23-libstdc++ (make TOOLCHAIN=clang-22-libstdc++), which share cmake/clang-libstdc++-flags.cmake, plus a plain clang-23 toolchain.

Testing

  • make TOOLCHAIN=clang-22-libstdc++: fails before the fix with the same four CI failures; passes after, 1179/1179.
  • make TOOLCHAIN=clang-23-libstdc++ and make TOOLCHAIN=gcc-16: 1179/1179.

Wording

Adding a member to expected<void, E> makes specgen emit the void_ alternative into the synopsis, because of a tie in how it orders edits: steve-downey/specgen#147. The checked-in wording is not regenerated here.

Every local clang toolchain forces -stdlib=libc++, and the std::expected
equivalence gate is skipped on libc++, so no local build ran that gate
with clang. CI's clang jobs run libstdc++ too, and that is where they fail.

clang-22-libstdc++ and clang-23-libstdc++ share clang-libstdc++-flags.cmake,
which takes clang-flags.cmake and swaps only the standard library. Also add
a plain clang-23 toolchain alongside clang-22.
…is eligible

For a move-only E, neither the trivial nor the non-trivial copy assignment
of expected is satisfied. [class.prop] counts only eligible special members,
so expected<int, MoveOnly> is trivially copyable, and gcc says so. Clang (17
through at least 24) does not: a class whose copy-assignment candidates all
have unsatisfied constraints is, to clang, not trivially copyable.

libstdc++ 16 made std::expected<int, MoveOnly> trivially copyable, so the
equivalence gate's "beman is at least as trivial as std" check now fails in
every clang job built against libstdc++ in CI.

Add a deleted copy assignment, satisfied exactly when neither path is, to the
primary template, expected<void, E> and expected<T&, E>. A deleted candidate
is not eligible either, so the triviality it restores is the standard's. It
is `\omit`ted from the wording; the rationale sits after the primary template
because specgen carries a plain comment on an omitted member into the
synopsis. Move assignment is left alone: a satisfied deleted move assignment
would stop an rvalue falling back to copy assignment.

The same bug was behind the equivalence gate's Immovable skip, which is
removed. expected_trivial gains a direct check, which runs on libc++ as well.
@steve-downey
steve-downey merged commit 1875b7a into bemanproject:main Oct 18, 2026
78 checks passed
steve-downey added a commit that referenced this pull request Oct 18, 2026
…nches (#101)

Supersedes #80 and #82, rebuilt on current `main`. Both had fallen
behind the switch from `static_assert` to runtime `CHECK`s and the
conversion tests added since.

### What's here

- **Remaining unexercised members** (from #80). Tests for the
out-of-line definitions no test reached. Most are `const&` overloads
shadowed by their `&&` twins: `const unexpected<G>&` construction and
assignment, converting move constructors, `error_or() &&`, the `const&`
monadic overloads of `expected<void, E>` and `expected<T&, E>`, the
ref-qualified `error()` overloads of `expected<T&, E>`, its `unexpect_t`
+ `initializer_list` constructor, and
`bad_expected_access<void>::what()`. Also excludes
`BEMAN_EXPECTED_TRAP()` lines from the gcovr report: reaching one kills
the process before libgcov flushes, so they can't be covered by
construction.
- **Reference specializations in the hardened build** (from #82).
`expected_hardened.test.cpp` never instantiated `expected<T&, E>` or any
reference `E`, so 6 of the 21 trap sites were never compiled. It now
covers all four reference shapes.
- **`expected<void, E&>` conversion tests that didn't convert.** Two
tests named for the `expected<void, G&>` converting constructors used
the same type on both sides, so they ran the copy and move constructors
instead. Their destinations now bind `const int&`.
- **Copy assignment with a non-trivial `E`.** The existing
copy-assignment tests for `expected<void, E>` and `expected<T&, E>` all
use `int`, which takes the trivial path, so the non-trivial operator's
mixed-state branches never ran. These tests use `std::string`.

Where the PRs had `static_assert`s on types or traits, they're now
`CHECK`s, matching the rest of the suite. The one exception asserts the
`initializer_list` constructor under constant evaluation, which is the
thing being tested.

### Results

- gcc-16: 1248/1248 tests pass (1178 on `main`).
- Every instrumented line of `expected.hpp` (786/786) and
`unexpected.hpp` (28/28) now runs, not counting trap lines. On `main`,
18 instrumented lines of `expected.hpp` never ran. These figures come
from gcovr's JSON output, without the coveralls line counting.
- clang-22 and clang-23 against libstdc++ 16: all pass except
`beman::expected type traits match std::expected`. That failure is
already on `main`, in a file this PR doesn't touch, and #100 fixes it.
steve-downey added a commit that referenced this pull request Oct 19, 2026
…id, E> (#104)

gcc's `-fsanitize=address` build reports `-Wmaybe-uninitialized` on
reads of `unex_` in value-state `expected<void, E>` objects. Every
report is a false positive, since `has_val_` guards each read exactly.
They were 43 of the 44 warnings in the default Asan build with gcc 15.

- **Fix at the source.** Give the union an empty `struct {} void_;`
alternative and initialize it in every constructor that can end in the
value state, as libstdc++'s `expected<void, E>` does. The member is
unmarked, so specgen leaves it out of the synopsis. Its rationale sits
after the class body, because a comment inside the union would be
carried into the synopsis.
- **Move the remaining suppression.** The gcc-14/15/16 toolchain files
each added `-Wno-maybe-uninitialized` to the Asan flags, but the default
toolchain and gcc-12/13 did not. A plain `make` warned 44 times where
`make TOOLCHAIN=gcc-16` was clean. The suppression isn't version-gated,
so it now lives in `gcc-flags.cmake`. It's still needed for
`expected<T&, E>`, whose value constructor stores a runtime address, so
the same folding can't apply.

gcc-16: 1249/1249 tests pass.

The generated wording is unchanged. I checked that by regenerating it
with specgen 0.4.0 on top of this branch. Earlier specgen releases could
leak `void_;` into the `expected<void, E>` synopsis once this member sat
beside the `\omit`ted copy assignment from #100
(steve-downey/specgen#147), so regenerate with 0.4.0 or later.
@steve-downey
steve-downey deleted the clang-libstdcxx-triviality branch October 19, 2026 18:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant