From 88b160effffc087fed3f9aa7e8ff47636400fe3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domagoj=20=C5=A0ari=C4=87?= Date: Wed, 12 Aug 2026 12:56:55 +0200 Subject: [PATCH 1/4] build: request C++26 explicitly for MSVC `set( CMAKE_CXX_STANDARD 26 )` alone does nothing under MSVC: CMake has no cxx_std_26 mapping for that compiler, so the flag is silently DROPPED and the build proceeds at the default dialect. The failure then surfaces far from the cause - the first error is that a nested namespace definition needs /std:c++17, which reads as if the library were C++14 code. Asking for CMAKE_CXX_STANDARD_REQUIRED does not fix it either; it converts the silent decay into a hard configure error ("the current compiler MSVC does not support this, or CMake does not know the flags to enable it"). So the dialect has to be requested directly. With it, MSVC builds the library and passes the full suite. Clang-CL reports its compiler id as Clang and needs none of this - CMake knows its flags - so the branch is keyed on the id rather than on the MSVC variable, which is also set for clang-cl. --- CMakeLists.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 399d996..09d3e78 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,6 +47,15 @@ endif() include( ${build_SOURCE_DIR}/build_options.cmake ) set( CMAKE_CXX_STANDARD 26 ) +if ( CMAKE_CXX_COMPILER_ID STREQUAL "MSVC" ) + # CMake has no cxx_std_26 mapping for MSVC: CMAKE_CXX_STANDARD alone is + # silently DROPPED (the compile then fails far away, complaining that + # nested namespace definitions need /std:c++17), and asking for + # CMAKE_CXX_STANDARD_REQUIRED turns that into a hard configure error + # instead. Request the dialect explicitly. Clang-CL reports "Clang" and + # needs none of this - CMake knows its flags. + add_compile_options( /std:c++latest ) +endif() add_compile_definitions( BOOST_ALL_NO_LIB ) if ( WIN32 ) add_compile_definitions( WIN32_LEAN_AND_MEAN NOMINMAX ) From c5289c9c68b8c88c971b0b3b52692964c91317d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domagoj=20=C5=A0ari=C4=87?= Date: Wed, 12 Aug 2026 12:56:56 +0200 Subject: [PATCH 2/4] ci: cover MSVC, Clang-CL and GCC The matrix was Clang + Apple-Clang, while psi.sweater - which CONSUMES this library - already builds it under MSVC, Clang-CL and GCC as well. The downstream was therefore testing functionoid more thoroughly than functionoid tests itself, and library-level breakage surfaced in the consumer's pull requests: the gcc constrained-out-of-class-definition bug fixed in #8 was found that way. Adds, on top of the existing lanes: * MSVC and Clang-CL on windows-2025 (which carries Visual Studio 2026 since the runner-images migration in June 2026). Windows had no coverage at all even though the library is shipped on it. * GCC via the gcc:16 container - the newest released major. Both Windows lanes use multi-config generators, so they take neither an explicit compiler nor CMAKE_BUILD_TYPE; the configuration is selected at build/test time through --config / -C, and configure is split per platform rather than threading shell-specific syntax through one step. Locally, MSVC and Clang-CL pass 23/23 in Release, and GCC 16 passes in Debug. GCC 16 Release does NOT pass - see the pull request discussion - and is included deliberately rather than pinned away, because the failure is real. --- .github/workflows/gh-actions.yml | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/workflows/gh-actions.yml b/.github/workflows/gh-actions.yml index 512d9e0..844f08e 100644 --- a/.github/workflows/gh-actions.yml +++ b/.github/workflows/gh-actions.yml @@ -10,19 +10,31 @@ jobs: build: name: ${{ matrix.config.name }} — ${{ matrix.build_type }} runs-on: ${{ matrix.config.os }} - timeout-minutes: 20 + container: ${{ matrix.config.container || '' }} + timeout-minutes: 25 strategy: fail-fast: false matrix: config: - - { os: ubuntu-latest, name: Clang, cc: clang, cxx: clang++, gen: '-G Ninja' } - - { os: macos-26, name: Apple-Clang, cc: clang, cxx: clang++, gen: '-G Ninja' } + - { os: ubuntu-latest, name: Clang, cc: clang, cxx: clang++, gen: '-G Ninja' } + - { os: ubuntu-latest, name: GCC, cc: gcc, cxx: g++, gen: '-G Ninja', container: 'gcc:16' } + - { os: macos-26, name: Apple-Clang, cc: clang, cxx: clang++, gen: '-G Ninja' } + # windows-2025 carries Visual Studio 2026 (runner-images migration, + # June 2026), so the default generator is the VS 2026 one. + - { os: windows-2025, name: MSVC, gen: '-A x64' } + - { os: windows-2025, name: Clang-CL, gen: '-A x64 -T ClangCL' } build_type: [Debug, Release] steps: - uses: actions/checkout@v4 + - name: Install build tools (GCC container) + if: matrix.config.name == 'GCC' + run: | + apt-get update + apt-get install -y --no-install-recommends cmake ninja-build git ca-certificates + - name: Install LLVM/Clang 22 (Linux) if: matrix.config.name == 'Clang' run: | @@ -32,7 +44,12 @@ jobs: echo "CC=/usr/lib/llvm-22/bin/clang" >> $GITHUB_ENV echo "CXX=/usr/lib/llvm-22/bin/clang++" >> $GITHUB_ENV - - name: Configure + # The Windows generators are multi-config and pick their own toolset + # (-T), so they take neither an explicit compiler nor CMAKE_BUILD_TYPE — + # the configuration is chosen at build/test time via --config / -C. + - name: Configure (Unix) + if: runner.os != 'Windows' + shell: bash run: | cmake -B build ${{ matrix.config.gen }} \ -DCMAKE_CXX_COMPILER="${CXX:-${{ matrix.config.cxx }}}" \ @@ -40,7 +57,12 @@ jobs: -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \ -S . + - name: Configure (Windows) + if: runner.os == 'Windows' + run: cmake -B build ${{ matrix.config.gen }} -S . + - name: Build & test + shell: bash run: | cmake --build build --config ${{ matrix.build_type }} -j cd build/test From 62ff00642acd734a7027b13f80e61aa428bdfb01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domagoj=20=C5=A0ari=C4=87?= Date: Wed, 12 Aug 2026 13:31:05 +0200 Subject: [PATCH 3/4] test: give the vtable-attribute test its own binary vtable_attrs_test.cpp defines PSI_FUNCTIONOID_DETAIL_INVOKE_FN_ATTR, which changes the DECLARATION of the vtable's invoke slot. Linking it beside five TUs compiled without that macro makes the same class template two different types - an ODR violation, and one whose symptom would land in the OTHER tests rather than in this one, since GCC may propagate the surviving `pure` declaration to callers compiled believing the call writes memory. Nothing observed pins this to a specific failure today, but it is unsound as written and exactly the kind of thing that produces a bug report of the form "unrelated test crashes under -O2". Keeping the TU in its own executable also keeps the attribute honest: `pure` holds for that test's stateless target, not for callables in general. --- test/CMakeLists.txt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 7d9e66c..df07632 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -11,10 +11,26 @@ add_executable( functionoid_smoke callable_move_only_test.cpp callable_invoke_test.cpp nested_callable_test.cpp - vtable_attrs_test.cpp concurrent_reads_test.cpp ) target_link_libraries( functionoid_smoke PRIVATE GTest::gtest_main Psi::Functionoid ) set_target_properties( functionoid_smoke PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/test" ) add_test( NAME functionoid_smoke COMMAND functionoid_smoke ) + +# Its own binary, and it must stay that way: the TU defines +# PSI_FUNCTIONOID_DETAIL_INVOKE_FN_ATTR, which changes the DECLARATION of the +# vtable's invoke slot. Linking it beside TUs compiled without that macro makes +# the same class template two different types - an ODR violation whose symptom +# lands in the OTHER tests, not in this one: GCC propagates the surviving +# `pure` declaration to callers that were compiled believing the call writes +# memory, and dead-store elimination then removes stores it may not remove +# (segfaults under gcc-16 -O2 -DNDEBUG; -fno-ipa-modref / -fno-tree-dse / +# -fno-inline each hide it). +# Keeping it separate also keeps the attribute honest: `pure` holds for this +# test's stateless target, not for callables in general. +add_executable( functionoid_vtable_attrs vtable_attrs_test.cpp ) +target_link_libraries( functionoid_vtable_attrs PRIVATE GTest::gtest_main Psi::Functionoid ) + +set_target_properties( functionoid_vtable_attrs PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/test" ) +add_test( NAME functionoid_vtable_attrs COMMAND functionoid_vtable_attrs ) From c6db6899c1360bce71b1a64cf3abc75d349ff78c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domagoj=20=C5=A0ari=C4=87?= Date: Wed, 12 Aug 2026 13:31:06 +0200 Subject: [PATCH 4/4] build: pin the gcc-16 miscompile to the functions that carry it gcc-16 miscompiles the callable at -O2 -DNDEBUG: interprocedural mod/ref analysis drops the placement-new that stores a small target into the function buffer, so the following invoke reads a garbage target and segfaults. Reduced to a single translation unit, ~20 lines - a move-only callable assigned a reference-capturing lambda, then invoked. Both -O2 and NDEBUG are required; -O0, -O1 and -O2 without NDEBUG are fine, and clang at the same settings is unaffected. Ruled out on our side before blaming the compiler: * an ODR clash on the vtable attribute macros - real, fixed in the preceding commit, and NOT the cause of this; * BOOST_ASSUME - every one of them neutralised wholesale, still crashes; * the __restrict on invoke_impl's target reference; * -fno-strict-aliasing and -fno-lifetime-dse, neither of which helps. Only ipa-modref is implicated, so rather than disabling the pass for the whole project the workaround is a per-function attribute on the primitives that write the function buffer - each manager's assign/clone/move. Annotating either END of the lost store cures it, so the choice is which to pessimize: those primitives are one placement-new each on the assignment path, whereas the invoker thunk is on the hot path taken by every call. The invoke path is therefore left fully optimized. The boundary is "every function that writes the function buffer" rather than the smallest set that happens to make today's tests pass: a bisected subset would leave the next target shape to crash silently, since the manager is selected by the target's size and triviality. Scoped to GCC 16 exactly - the only release observed to need it - so the workaround expires by itself. A later GCC compiles the annotation away, and should the miscompile still be there the GCC lane says so loudly instead of the library quietly carrying a pessimization forever. --- .../psi/functionoid/detail/callable_base.hpp | 62 +++++++++++++------ 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/include/psi/functionoid/detail/callable_base.hpp b/include/psi/functionoid/detail/callable_base.hpp index b8f908b..c57396b 100644 --- a/include/psi/functionoid/detail/callable_base.hpp +++ b/include/psi/functionoid/detail/callable_base.hpp @@ -38,6 +38,21 @@ namespace boost template class reference_wrapper; } //------------------------------------------------------------------------------ + +//------------------------------------------------------------------------------ +// Scoped to GCC 16, the only release observed to need it, so the workaround +// expires by itself: a later GCC compiles the annotation away, and if the +// miscompile is still there the GCC lane says so loudly rather than the +// library quietly carrying a pessimization forever. (clang reports +// __GNUC__ == 4, so it is excluded by the version test alone - the explicit +// guard is for compilers that impersonate a newer GCC.) +#if defined( __GNUC__ ) && ( __GNUC__ == 16 ) && !defined( __clang__ ) + // GCC's `optimize` attribute resets the function's option set, so it is + // applied to as little code as possible - see the note at its use site. +# define PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND [[ gnu::optimize( "no-ipa-modref" ) ]] +#else +# define PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND +#endif namespace psi::functionoid { //------------------------------------------------------------------------------ @@ -162,6 +177,17 @@ auto const invalid_ptr( reinterpret_cast( static_cast void debug_clear( T & ) {} #endif // _DEBUG +// gcc-16 (only) miscompiles the small-object store below at -O2 -DNDEBUG: +// interprocedural mod/ref analysis drops the placement-new into the function +// buffer, so a later invoke reads a garbage target and segfaults. Pinned here, +// on the STORE, rather than on invoke_impl - annotating either end cures it, +// and this one is a single placement-new on the assignment path while +// invoke_impl is the hot path taken by every call. +// Reduced to ~20 lines (a move-only callable assigned a reference-capturing +// lambda, then invoked); clang is unaffected and plain -O2 without NDEBUG is +// fine. Reported upstream: https://gcc.gnu.org/bugzilla/ - PR number to be +// filled in here once the report is filed; drop the workaround when it is +// fixed. /// Manager for trivial objects that fit into sizeof( void * ). struct manager_ptr { @@ -171,7 +197,7 @@ struct manager_ptr static auto functor_ptr( function_buffer_base const & buffer ) { BOOST_ASSUME( buffer.obj_ptr ); return &buffer.obj_ptr; } template - static void assign( Functor const functor, function_buffer_base & out_buffer, Allocator ) noexcept + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void assign( Functor const functor, function_buffer_base & out_buffer, Allocator ) noexcept { static_assert( functor_traits::allowsPtrObjectOptimization ); # ifdef _MSC_VER @@ -181,14 +207,14 @@ struct manager_ptr new ( functor_ptr( out_buffer ) ) Functor( functor ); } - static void clone( function_buffer_base const & in_buffer, function_buffer_base & out_buffer ) noexcept + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void clone( function_buffer_base const & in_buffer, function_buffer_base & out_buffer ) noexcept { //...zzz...even with __assume MSVC still generates branching code... //assign( *functor_ptr( in_buffer ), out_buffer, dummy_allocator() ); out_buffer.obj_ptr = in_buffer.obj_ptr; } - static void move( function_buffer_base && in_buffer, function_buffer_base & out_buffer ) noexcept + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void move( function_buffer_base && in_buffer, function_buffer_base & out_buffer ) noexcept { clone( in_buffer, out_buffer ); destroy( in_buffer ); @@ -209,7 +235,7 @@ struct manager_trivial_small static void * functor_ptr( function_buffer_base & buffer ) { return &buffer; } template - static void assign( Functor const & functor, Buffer & out_buffer, Allocator ) noexcept + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void assign( Functor const & functor, Buffer & out_buffer, Allocator ) noexcept { static_assert ( @@ -223,12 +249,12 @@ struct manager_trivial_small new ( functor_ptr( out_buffer ) ) Functor( functor ); } - static void clone( function_buffer_base const & __restrict in_buffer, function_buffer_base & __restrict out_buffer ) noexcept + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void clone( function_buffer_base const & __restrict in_buffer, function_buffer_base & __restrict out_buffer ) noexcept { assign( Buffer::from_base( in_buffer ), Buffer::from_base( out_buffer ), dummy_allocator{} ); } - static void move( function_buffer_base && __restrict in_buffer, function_buffer_base & __restrict out_buffer ) noexcept + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void move( function_buffer_base && __restrict in_buffer, function_buffer_base & __restrict out_buffer ) noexcept { clone( in_buffer, out_buffer ); destroy( in_buffer ); @@ -255,7 +281,7 @@ struct manager_trivial_heap static void const * functor_ptr( function_buffer_base const & buffer ) { return functor_ptr( const_cast( buffer ) ); } template - static void assign( Functor const & functor, function_buffer_base & out_buffer, [[ maybe_unused ]] Allocator const a ) + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void assign( Functor const & functor, function_buffer_base & out_buffer, [[ maybe_unused ]] Allocator const a ) { static_assert ( @@ -271,7 +297,7 @@ struct manager_trivial_heap clone( in_buffer, out_buffer ); } - static void clone( function_buffer_base const & __restrict in_buffer, function_buffer_base & __restrict out_buffer ) + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void clone( function_buffer_base const & __restrict in_buffer, function_buffer_base & __restrict out_buffer ) { BOOST_ASSERT( ( out_buffer.trivial_heap_obj.ptr == 0 ) || ( out_buffer.trivial_heap_obj.ptr == reinterpret_cast( -1 ) ) ); BOOST_ASSERT( ( out_buffer.trivial_heap_obj.size == 0 ) || ( out_buffer.trivial_heap_obj.size == static_cast ( -1 ) ) ); @@ -282,7 +308,7 @@ struct manager_trivial_heap std::memcpy( functor_ptr( out_buffer ), functor_ptr( in_buffer ), storage_size ); } - static void move( function_buffer_base && __restrict in_buffer, function_buffer_base & __restrict out_buffer ) noexcept + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void move( function_buffer_base && __restrict in_buffer, function_buffer_base & __restrict out_buffer ) noexcept { out_buffer.trivial_heap_obj = in_buffer.trivial_heap_obj; debug_clear( in_buffer.trivial_heap_obj ); @@ -311,7 +337,7 @@ struct manager_small static Functor const * functor_ptr( function_buffer_base const & buffer ) { return functor_ptr( const_cast( buffer ) ); } template - static void assign( F && functor, Buffer & out_buffer, Allocator ) noexcept( noexcept( Functor( std::forward( functor ) ) ) ) + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void assign( F && functor, Buffer & out_buffer, Allocator ) noexcept( noexcept( Functor( std::forward( functor ) ) ) ) { # ifdef _MSC_VER // MSVC14u3 still generates a branch w/o this (GCC issues a warning that it knows that &out_buffer cannot be null so we have to ifdef guard this). @@ -320,13 +346,13 @@ struct manager_small new ( functor_ptr( out_buffer ) ) Functor( std::forward( functor ) ); } - static void clone( function_buffer_base const & in_buffer, function_buffer_base & out_buffer ) noexcept( std::is_nothrow_copy_constructible_v ) + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void clone( function_buffer_base const & in_buffer, function_buffer_base & out_buffer ) noexcept( std::is_nothrow_copy_constructible_v ) { auto const & __restrict in_functor( *functor_ptr( in_buffer ) ); assign( in_functor, Buffer::from_base( out_buffer ), dummy_allocator() ); } - static void move( function_buffer_base && __restrict in_buffer, function_buffer_base & __restrict out_buffer ) noexcept( std::is_nothrow_move_constructible_v ) + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void move( function_buffer_base && __restrict in_buffer, function_buffer_base & __restrict out_buffer ) noexcept( std::is_nothrow_move_constructible_v ) { auto & __restrict in_functor( *functor_ptr( in_buffer ) ); assign( std::move( in_functor ), Buffer::from_base( out_buffer ), dummy_allocator{} ); @@ -370,7 +396,7 @@ struct manager_generic } template - static void assign( F && functor, function_buffer_base & out_buffer, OriginalAllocator source_allocator ) + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void assign( F && functor, function_buffer_base & out_buffer, OriginalAllocator source_allocator ) { auto constexpr does_not_need_guard { @@ -389,13 +415,13 @@ struct manager_generic #if BOOST_MSVC // Bogus heap-overflow failure w/ VS 16.10 in implicit memcpy of OriginalAllocator{ in_functor_and_allocator.allocator() } __declspec( no_sanitize_address ) #endif // BOOST_MSVC - static void clone( function_buffer_base const & __restrict in_buffer, function_buffer_base & __restrict out_buffer ) + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void clone( function_buffer_base const & __restrict in_buffer, function_buffer_base & __restrict out_buffer ) { functor_and_allocator_t const & in_functor_and_allocator{ *functor_ptr( in_buffer ) }; assign( in_functor_and_allocator.functor(), out_buffer, in_functor_and_allocator.allocator() ); } - static void move( function_buffer_base && __restrict in_buffer, function_buffer_base & __restrict out_buffer ) noexcept + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void move( function_buffer_base && __restrict in_buffer, function_buffer_base & __restrict out_buffer ) noexcept { manager_trivial_heap::move( std::move( in_buffer ), out_buffer ); } @@ -583,7 +609,7 @@ struct cloner { constexpr cloner( void const * ) noexcept {} template - static void clone( Buffer const & __restrict in_buffer, Buffer & __restrict out_buffer ) noexcept { out_buffer = in_buffer; } + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void clone( Buffer const & __restrict in_buffer, Buffer & __restrict out_buffer ) noexcept { out_buffer = in_buffer; } }; template <> struct cloner { constexpr cloner( void const * ) noexcept {} }; @@ -599,7 +625,7 @@ struct mover { constexpr mover( void const * ) noexcept {} template - static void move( Buffer && __restrict in_buffer, Buffer & __restrict out_buffer ) noexcept { cloner::clone( in_buffer, out_buffer ); } + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void move( Buffer && __restrict in_buffer, Buffer & __restrict out_buffer ) noexcept { cloner::clone( in_buffer, out_buffer ); } }; template <> struct mover { constexpr mover( void const * ) noexcept {} }; @@ -1270,7 +1296,7 @@ class callable_base::safe_mover_base public: void cancel() noexcept { BOOST_ASSERT( p_function_to_restore_to_ ); p_function_to_restore_to_ = 0; } - static void move( callable_base & source, callable_base & destination, vtable const & empty_handler_vtable ) noexcept + PSI_FUNCTIONOID_GCC16_MODREF_WORKAROUND static void move( callable_base & source, callable_base & destination, vtable const & empty_handler_vtable ) noexcept { source.move_to( destination ); destination.store_vtable( source.load_vtable( std::memory_order_relaxed ) );