From b0af665a84857b7b8e8637f42c9e966dca95f47d Mon Sep 17 00:00:00 2001 From: X1aomu Date: Fri, 7 Aug 2026 13:52:45 +0800 Subject: [PATCH 01/24] test(msvc-p0323): isolate the permissive-mode probe The imported helper depends on MSVC STL's private and uses an MSVC-specific lookup probe to account for /permissive behavior. That header is unavailable in GCC and Clang environments. Keep the original probe unchanged under _MSC_VER. Non-MSVC builds use the strict conformance path, so provide is_permissive as false without importing a vendor-private header. This affects only the test harness and leaves the expected implementation untouched. --- tests/third_party/msvc/include/is_permissive.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/third_party/msvc/include/is_permissive.hpp b/tests/third_party/msvc/include/is_permissive.hpp index 007ae88..c022e10 100644 --- a/tests/third_party/msvc/include/is_permissive.hpp +++ b/tests/third_party/msvc/include/is_permissive.hpp @@ -3,6 +3,7 @@ #pragma once +#ifdef _MSC_VER #include namespace detail { @@ -29,3 +30,6 @@ template constexpr bool is_permissive_v = detail::PermissiveTest::test(); _INLINE_VAR constexpr bool is_permissive = is_permissive_v; +#else +inline constexpr bool is_permissive = false; +#endif From d7eea31a572e14b37759b926b84effb1af22ecb2 Mon Sep 17 00:00:00 2001 From: X1aomu Date: Fri, 7 Aug 2026 13:56:18 +0800 Subject: [PATCH 02/24] test(msvc-p0323): avoid GCC local-class alias lookup The imported test computes constexpr bool aliases in an enclosing function and then names them in requires-clauses and noexcept-specifications of local-class members. GCC 15 can resolve those names incorrectly during template instantiation, reporting values such as should_be_trivial as being used in their own initializer and deriving the wrong constraints or exception specifications. Spell the same IsYes expressions directly from the non-type template parameters inside each local class. Keep the aliases for the surrounding assertions, preserve every truth table, and retain the vendor file's original line structure. Clang and MSVC semantics are unchanged. --- .../msvc_stl_p0323r12_test.cpp | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/tests/third_party/msvc/msvc_stl_p0323r12_test/msvc_stl_p0323r12_test.cpp b/tests/third_party/msvc/msvc_stl_p0323r12_test/msvc_stl_p0323r12_test.cpp index 4807440..ac85e20 100644 --- a/tests/third_party/msvc/msvc_stl_p0323r12_test/msvc_stl_p0323r12_test.cpp +++ b/tests/third_party/msvc/msvc_stl_p0323r12_test/msvc_stl_p0323r12_test.cpp @@ -103,21 +103,21 @@ namespace test_unexpected { constexpr bool compare_is_noexcept = IsYes(nothrowComparable); struct test_error { - constexpr test_error(const int& val) noexcept(copy_construction_is_noexcept) : _val(val) {} - constexpr test_error(int&& val) noexcept(move_construction_is_noexcept) : _val(val) {} + constexpr test_error(const int& val) noexcept(IsYes(nothrowCopyConstructible)) : _val(val) {} + constexpr test_error(int&& val) noexcept(IsYes(nothrowMoveConstructible)) : _val(val) {} - constexpr test_error(initializer_list, const int& val) noexcept(copy_construction_is_noexcept) + constexpr test_error(initializer_list, const int& val) noexcept(IsYes(nothrowCopyConstructible)) : _val(val) {} - constexpr test_error(initializer_list, int&& val) noexcept(move_construction_is_noexcept) + constexpr test_error(initializer_list, int&& val) noexcept(IsYes(nothrowMoveConstructible)) : _val(val) {} - constexpr test_error(const convertible& other) noexcept(copy_construction_is_noexcept) : _val(other._val) {} - constexpr test_error(convertible&& other) noexcept(move_construction_is_noexcept) : _val(other._val) {} + constexpr test_error(const convertible& other) noexcept(IsYes(nothrowCopyConstructible)) : _val(other._val) {} + constexpr test_error(convertible&& other) noexcept(IsYes(nothrowMoveConstructible)) : _val(other._val) {} - [[nodiscard]] constexpr bool operator==(const test_error& right) const noexcept(compare_is_noexcept) { + [[nodiscard]] constexpr bool operator==(const test_error& right) const noexcept(IsYes(nothrowComparable)) { return _val == right._val; } - [[nodiscard]] constexpr bool operator==(const convertible& right) const noexcept(compare_is_noexcept) { + [[nodiscard]] constexpr bool operator==(const convertible& right) const noexcept(IsYes(nothrowComparable)) { return _val == right._val; } @@ -286,8 +286,8 @@ namespace test_expected { struct payload_copy_constructor { payload_copy_constructor() = default; payload_copy_constructor& operator=(const payload_copy_constructor&) = delete; - constexpr payload_copy_constructor(const payload_copy_constructor&) noexcept(should_be_noexcept) - requires (!should_be_trivial) + constexpr payload_copy_constructor(const payload_copy_constructor&) noexcept(IsYes(triviallyCopyConstructible) || IsYes(nothrowCopyConstructible)) + requires (!IsYes(triviallyCopyConstructible)) : _val(42) {} constexpr payload_copy_constructor(const payload_copy_constructor&) = default; @@ -374,8 +374,8 @@ namespace test_expected { payload_move_constructor() = default; payload_move_constructor(const payload_move_constructor&) = default; payload_move_constructor& operator=(payload_move_constructor&&) = delete; - constexpr payload_move_constructor(payload_move_constructor&&) noexcept(should_be_noexcept) - requires (!should_be_trivial) + constexpr payload_move_constructor(payload_move_constructor&&) noexcept(IsYes(triviallyMoveConstructible) || IsYes(nothrowMoveConstructible)) + requires (!IsYes(triviallyMoveConstructible)) : _val(42) {} constexpr payload_move_constructor(payload_move_constructor&&) = default; @@ -521,13 +521,13 @@ namespace test_expected { payload_constructors() = default; // Note clang does not accept local variables in explicit constexpr explicit(IsYes(explicitConstructible)) payload_constructors(const convertible&) - noexcept(should_be_noexcept) + noexcept(IsYes(nothrowConstructible)) : _val(3) {} constexpr explicit(IsYes(explicitConstructible)) payload_constructors(convertible&&) - noexcept(should_be_noexcept) + noexcept(IsYes(nothrowConstructible)) : _val(42) {} constexpr explicit(IsYes(explicitConstructible)) payload_constructors(initializer_list&, convertible) - noexcept(should_be_noexcept) + noexcept(IsYes(nothrowConstructible)) : _val(1337) {} [[nodiscard]] constexpr bool operator==(const int val) const noexcept { @@ -739,26 +739,26 @@ namespace test_expected { struct payload_assign { payload_assign() = default; constexpr payload_assign(const int val) noexcept : _val(val) {} - constexpr payload_assign(const payload_assign& other) noexcept(nothrow_copy_constructible) + constexpr payload_assign(const payload_assign& other) noexcept(IsYes(nothrowCopyConstructible)) : _val(other._val) {} - constexpr payload_assign(payload_assign&& other) noexcept(nothrow_move_constructible) : _val(other._val) {} - constexpr payload_assign& operator=(const payload_assign& other) noexcept(nothrow_copy_assignable) { + constexpr payload_assign(payload_assign&& other) noexcept(IsYes(nothrowMoveConstructible)) : _val(other._val) {} + constexpr payload_assign& operator=(const payload_assign& other) noexcept(IsYes(nothrowCopyAssignable)) { _val = other._val; return *this; } - constexpr payload_assign& operator=(payload_assign&& other) noexcept(nothrow_move_assignable) { + constexpr payload_assign& operator=(payload_assign&& other) noexcept(IsYes(nothrowMoveAssignable)) { _val = other._val; return *this; } - constexpr payload_assign(const convertible& other) noexcept(nothrow_copy_constructible) + constexpr payload_assign(const convertible& other) noexcept(IsYes(nothrowCopyConstructible)) : _val(other._val) {} - constexpr payload_assign(convertible&& other) noexcept(nothrow_move_constructible) : _val(other._val) {} - constexpr payload_assign& operator=(const convertible& other) noexcept(nothrow_copy_assignable) { + constexpr payload_assign(convertible&& other) noexcept(IsYes(nothrowMoveConstructible)) : _val(other._val) {} + constexpr payload_assign& operator=(const convertible& other) noexcept(IsYes(nothrowCopyAssignable)) { _val = other._val; return *this; } - constexpr payload_assign& operator=(convertible&& other) noexcept(nothrow_move_assignable) { + constexpr payload_assign& operator=(convertible&& other) noexcept(IsYes(nothrowMoveAssignable)) { _val = other._val; return *this; } @@ -2074,7 +2074,7 @@ namespace test_expected { struct payload_equality { constexpr payload_equality(const int val) noexcept : _val(val) {} - [[nodiscard]] constexpr bool operator==(const payload_equality& right) const noexcept(should_be_noexcept) { + [[nodiscard]] constexpr bool operator==(const payload_equality& right) const noexcept(IsYes(nothrowComparable)) { return _val == right._val; } From c58329bfdd893f64d0490642b48e082a8bb7dda2 Mon Sep 17 00:00:00 2001 From: X1aomu Date: Fri, 7 Aug 2026 13:58:49 +0800 Subject: [PATCH 03/24] test(msvc-p0323): preserve braced-value coverage The imported Expected{{}} case is meant to exercise the value constructor's default U = T. A conforming list-initialization candidate set also admits the in_place_t and unexpect_t constructors for an empty argument. Explicit constructors participate under CWG 1228, and CWG 1229 documents the resulting empty-list ambiguity. GCC therefore rejects both this implementation and std::expected; treating this as a GCC-only workaround would be backwards. Use Expected{{payload_constructors{}}} instead of passing a typed payload directly. The argument remains a braced-init-list, so U cannot be deduced and its default T remains under test. The payload element removes the tag ambiguity while retaining the default-constructed value, has_value, value == 0, and noexcept checks. Refs: https://cplusplus.github.io/CWG/issues/1228.html Refs: https://cplusplus.github.io/CWG/issues/1229.html --- .../msvc/msvc_stl_p0323r12_test/msvc_stl_p0323r12_test.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/third_party/msvc/msvc_stl_p0323r12_test/msvc_stl_p0323r12_test.cpp b/tests/third_party/msvc/msvc_stl_p0323r12_test/msvc_stl_p0323r12_test.cpp index ac85e20..091c01d 100644 --- a/tests/third_party/msvc/msvc_stl_p0323r12_test/msvc_stl_p0323r12_test.cpp +++ b/tests/third_party/msvc/msvc_stl_p0323r12_test/msvc_stl_p0323r12_test.cpp @@ -554,10 +554,10 @@ namespace test_expected { assert(move_constructed_value.value() == 42); static_assert(noexcept(Expected{Input{}}) == should_be_noexcept || is_permissive); - const Expected brace_constructed_value{{}}; + const Expected brace_constructed_value{{payload_constructors{}}}; assert(brace_constructed_value); assert(brace_constructed_value.value() == 0); - static_assert(noexcept(Expected{{}})); + static_assert(noexcept(Expected{{payload_constructors{}}})); } { // converting from different expected From 296e612def214e22ef543096ef2372f14f7b7084 Mon Sep 17 00:00:00 2001 From: X1aomu Date: Fri, 7 Aug 2026 12:58:57 +0800 Subject: [PATCH 04/24] build(msvc-p0323): enable on recent GCC and Clang Build the P0323 conformance test with GCC 15+ and Clang 22+ in strict C++20 mode, while keeping older compiler matrices unchanged and suppressing vendor-source Clang warnings. --- tests/third_party/CMakeLists.txt | 13 +++++++++++++ tests/third_party/msvc/CMakeLists.txt | 7 +++++++ .../msvc/msvc_stl_p0323r12_test/CMakeLists.txt | 6 +++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/third_party/CMakeLists.txt b/tests/third_party/CMakeLists.txt index a892335..be59824 100644 --- a/tests/third_party/CMakeLists.txt +++ b/tests/third_party/CMakeLists.txt @@ -1,3 +1,16 @@ +set(_zeus_expected_enable_msvc_stl_tests OFF) if (MSVC) + set(_zeus_expected_enable_msvc_stl_tests ON) +elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" + AND CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 15) + set(_zeus_expected_enable_msvc_stl_tests ON) +elseif (CMAKE_CXX_COMPILER_ID STREQUAL "Clang" + AND CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 22) + set(_zeus_expected_enable_msvc_stl_tests ON) +endif () + +if (_zeus_expected_enable_msvc_stl_tests) add_subdirectory(msvc) endif () + +unset(_zeus_expected_enable_msvc_stl_tests) diff --git a/tests/third_party/msvc/CMakeLists.txt b/tests/third_party/msvc/CMakeLists.txt index 7126488..fa012b0 100644 --- a/tests/third_party/msvc/CMakeLists.txt +++ b/tests/third_party/msvc/CMakeLists.txt @@ -1,5 +1,12 @@ include_directories(SYSTEM "include") +if (NOT MSVC AND CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + add_compile_options( + -Wno-deprecated-volatile + -Wno-unqualified-std-cast-call + ) +endif () + add_subdirectory(msvc_stl_p0323r12_test) if (MSVC_TOOLSET_VERSION GREATER_EQUAL 143) # can only built with v143 and later diff --git a/tests/third_party/msvc/msvc_stl_p0323r12_test/CMakeLists.txt b/tests/third_party/msvc/msvc_stl_p0323r12_test/CMakeLists.txt index bf211e0..173b464 100644 --- a/tests/third_party/msvc/msvc_stl_p0323r12_test/CMakeLists.txt +++ b/tests/third_party/msvc/msvc_stl_p0323r12_test/CMakeLists.txt @@ -1,6 +1,10 @@ project(msvc_stl_p0323r12_test LANGUAGES CXX) add_executable(${PROJECT_NAME}) -set_target_properties(${PROJECT_NAME} PROPERTIES CXX_STANDARD 20) +set_target_properties(${PROJECT_NAME} PROPERTIES + CXX_STANDARD 20 + CXX_STANDARD_REQUIRED ON + CXX_EXTENSIONS OFF +) target_link_libraries(${PROJECT_NAME} PRIVATE zeus::expected) From 105dd095f5e4f768a40bd81b94d3a4ff9fff3f52 Mon Sep 17 00:00:00 2001 From: X1aomu Date: Fri, 7 Aug 2026 12:59:11 +0800 Subject: [PATCH 05/24] build(msvc-p2505): enable on recent GCC and Clang Build the P2505 monadic conformance test outside MSVC when the parent compiler gate is satisfied, and require strict C++20 mode for the target. --- tests/third_party/msvc/CMakeLists.txt | 5 +++-- tests/third_party/msvc/msvc_stl_p2505r5_test/CMakeLists.txt | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/third_party/msvc/CMakeLists.txt b/tests/third_party/msvc/CMakeLists.txt index fa012b0..83f06e9 100644 --- a/tests/third_party/msvc/CMakeLists.txt +++ b/tests/third_party/msvc/CMakeLists.txt @@ -8,7 +8,8 @@ if (NOT MSVC AND CMAKE_CXX_COMPILER_ID STREQUAL "Clang") endif () add_subdirectory(msvc_stl_p0323r12_test) -if (MSVC_TOOLSET_VERSION GREATER_EQUAL 143) - # can only built with v143 and later +if (NOT MSVC OR MSVC_TOOLSET_VERSION GREATER_EQUAL 143) + # The MSVC build requires v143 or later. GCC and Clang are already + # version-gated by the parent directory. add_subdirectory(msvc_stl_p2505r5_test) endif () diff --git a/tests/third_party/msvc/msvc_stl_p2505r5_test/CMakeLists.txt b/tests/third_party/msvc/msvc_stl_p2505r5_test/CMakeLists.txt index d2adfcc..f7c1498 100644 --- a/tests/third_party/msvc/msvc_stl_p2505r5_test/CMakeLists.txt +++ b/tests/third_party/msvc/msvc_stl_p2505r5_test/CMakeLists.txt @@ -1,6 +1,10 @@ project(msvc_stl_p2505r5_test LANGUAGES CXX) add_executable(${PROJECT_NAME}) -set_target_properties(${PROJECT_NAME} PROPERTIES CXX_STANDARD 20) +set_target_properties(${PROJECT_NAME} PROPERTIES + CXX_STANDARD 20 + CXX_STANDARD_REQUIRED ON + CXX_EXTENSIONS OFF +) target_link_libraries(${PROJECT_NAME} PRIVATE zeus::expected) From 1fe914f6101b0522f5333eced7cbe62c419fb2bb Mon Sep 17 00:00:00 2001 From: X1aomu Date: Fri, 7 Aug 2026 12:59:34 +0800 Subject: [PATCH 06/24] fix: allow constexpr destruction with Clang Leave storage destructor exception specifications implicit. Explicit noexcept triggers LLVM-59854 when a non-trivial union alternative is destroyed during constant evaluation. --- include/zeus/expected.hpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/include/zeus/expected.hpp b/include/zeus/expected.hpp index 39d24b5..5838d3e 100644 --- a/include/zeus/expected.hpp +++ b/include/zeus/expected.hpp @@ -598,7 +598,9 @@ struct storage_base { } - ZEUS_EXPECTED_CONSTEXPR_DTOR ~storage_base() noexcept + // Keep the exception specification implicit; an explicit noexcept trips + // Clang's LLVM-59854 during constexpr destruction. + ZEUS_EXPECTED_CONSTEXPR_DTOR ~storage_base() { if (m_has_val) { @@ -738,7 +740,9 @@ struct storage_base { } - ZEUS_EXPECTED_CONSTEXPR_DTOR ~storage_base() noexcept + // Keep the exception specification implicit; an explicit noexcept trips + // Clang's LLVM-59854 during constexpr destruction. + ZEUS_EXPECTED_CONSTEXPR_DTOR ~storage_base() { if (!m_has_val) { From 0a38e81b1af837b59c9f9982f4f4e02f75e720de Mon Sep 17 00:00:00 2001 From: X1aomu Date: Fri, 7 Aug 2026 10:30:08 +0800 Subject: [PATCH 07/24] test: add configurable ASan and UBSan support Provide independent automatic and required modes for ASan and UBSan. Probe compiler, linker, and runtime support before enabling test-only instrumentation, and make required mode fail configuration when unavailable. Attach strict runtime options through CTest while disabling leak detection during Catch2 discovery. Support UBSan on GCC and Clang, and keep the implementation under tests/cmake so test CMakeLists remain focused on their high-level flow. --- tests/CMakeLists.txt | 4 + tests/cmake/ZeusExpectedTestSanitizers.cmake | 253 ++++++++++++++++++ tests/cmake/asan_default_options.cpp | 6 + tests/test_expected/CMakeLists.txt | 1 + tests/test_no_exceptions/CMakeLists.txt | 1 + .../msvc_stl_p0323r12_test/CMakeLists.txt | 1 + .../msvc/msvc_stl_p2505r5_test/CMakeLists.txt | 1 + 7 files changed, 267 insertions(+) create mode 100644 tests/cmake/ZeusExpectedTestSanitizers.cmake create mode 100644 tests/cmake/asan_default_options.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 898c030..e87fcb3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,3 +1,7 @@ +include("${CMAKE_CURRENT_LIST_DIR}/cmake/ZeusExpectedTestSanitizers.cmake") + +zeus_expected_enable_test_sanitizers() + add_subdirectory(test_expected) add_subdirectory(test_no_exceptions) add_subdirectory(third_party) diff --git a/tests/cmake/ZeusExpectedTestSanitizers.cmake b/tests/cmake/ZeusExpectedTestSanitizers.cmake new file mode 100644 index 0000000..7101c02 --- /dev/null +++ b/tests/cmake/ZeusExpectedTestSanitizers.cmake @@ -0,0 +1,253 @@ +include_guard(GLOBAL) + +include(CheckCXXSourceRuns) + +option(ZEUS_EXPECTED_TEST_ASAN_AUTO + "Automatically enable AddressSanitizer for tests when supported" + ON +) +option(ZEUS_EXPECTED_TEST_ASAN_REQUIRED + "Require AddressSanitizer support for tests" + OFF +) +option(ZEUS_EXPECTED_TEST_UBSAN_AUTO + "Automatically enable UndefinedBehaviorSanitizer for tests when supported" + ON +) +option(ZEUS_EXPECTED_TEST_UBSAN_REQUIRED + "Require UndefinedBehaviorSanitizer support for tests" + OFF +) +# REQUIRED takes precedence over AUTO for each sanitizer. With both options +# disabled, that sanitizer is not probed or enabled. + +function(_zeus_expected_check_asan_support RESULT_VARIABLE) + unset(_ZEUS_EXPECTED_TEST_ASAN_SUPPORTED CACHE) + if (CMAKE_CROSSCOMPILING AND NOT CMAKE_CROSSCOMPILING_EMULATOR) + set(${RESULT_VARIABLE} FALSE PARENT_SCOPE) + return() + endif () + + string(JOIN " " _required_flags + ${_zeus_expected_test_asan_compile_options} + ) + string(APPEND CMAKE_REQUIRED_FLAGS " ${_required_flags}") + list(APPEND CMAKE_REQUIRED_LINK_OPTIONS + ${_zeus_expected_test_asan_link_options} + ) + + check_cxx_source_runs([=[ + extern "C" const char* __asan_default_options() + { + return "detect_leaks=0"; + } + + int main() + { + return 0; + } + ]=] + _ZEUS_EXPECTED_TEST_ASAN_SUPPORTED + ) + set(${RESULT_VARIABLE} + "${_ZEUS_EXPECTED_TEST_ASAN_SUPPORTED}" + PARENT_SCOPE + ) +endfunction() + +function(_zeus_expected_enable_asan RESULT_VARIABLE) + set(${RESULT_VARIABLE} "" PARENT_SCOPE) + + if (NOT (ZEUS_EXPECTED_TEST_ASAN_AUTO + OR ZEUS_EXPECTED_TEST_ASAN_REQUIRED)) + message(STATUS "AddressSanitizer disabled for tests") + return() + endif () + + if (MSVC) + set(_zeus_expected_test_asan_compile_options /fsanitize=address) + set(_zeus_expected_test_asan_link_options) + else () + set(_zeus_expected_test_asan_compile_options + -fsanitize=address + -fno-omit-frame-pointer + ) + set(_zeus_expected_test_asan_link_options -fsanitize=address) + endif () + + _zeus_expected_check_asan_support( + ZEUS_EXPECTED_TEST_ASAN_SUPPORTED + ) + + if (ZEUS_EXPECTED_TEST_ASAN_SUPPORTED) + add_compile_options(${_zeus_expected_test_asan_compile_options}) + if (_zeus_expected_test_asan_link_options) + add_link_options(${_zeus_expected_test_asan_link_options}) + endif () + + if (WIN32) + set(_environment "ASAN_OPTIONS=halt_on_error=1") + else () + add_library(zeus_expected_asan_defaults OBJECT + "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/asan_default_options.cpp" + ) + link_libraries(zeus_expected_asan_defaults) + + set(_environment + "ASAN_OPTIONS=detect_leaks=1:halt_on_error=1" + ) + endif () + set(${RESULT_VARIABLE} "${_environment}" PARENT_SCOPE) + message(STATUS "AddressSanitizer enabled for tests") + elseif (ZEUS_EXPECTED_TEST_ASAN_REQUIRED) + message(FATAL_ERROR + "ZEUS_EXPECTED_TEST_ASAN_REQUIRED=ON, but the current compiler, " + "linker, or runtime environment cannot run " + "AddressSanitizer-instrumented test executables" + ) + else () + message(STATUS + "AddressSanitizer unavailable; tests will run without it" + ) + endif () +endfunction() + +function(_zeus_expected_check_ubsan_support RESULT_VARIABLE) + unset(_ZEUS_EXPECTED_TEST_UBSAN_SUPPORTED CACHE) + if (CMAKE_CROSSCOMPILING AND NOT CMAKE_CROSSCOMPILING_EMULATOR) + set(${RESULT_VARIABLE} FALSE PARENT_SCOPE) + return() + endif () + + string(JOIN " " _required_flags + ${_zeus_expected_test_ubsan_compile_options} + ) + string(APPEND CMAKE_REQUIRED_FLAGS " ${_required_flags}") + list(APPEND CMAKE_REQUIRED_LINK_OPTIONS + ${_zeus_expected_test_ubsan_link_options} + ) + + # Keep a safe division check in the executable so the linker must resolve + # the UBSan runtime, then run it to verify that the runtime is loadable. + check_cxx_source_runs([=[ + int main(int argc, char**) + { + volatile int divisor = argc; + const int result = 1 / divisor; + return result == 1 ? 0 : 1; + } + ]=] + _ZEUS_EXPECTED_TEST_UBSAN_SUPPORTED + ) + set(${RESULT_VARIABLE} + "${_ZEUS_EXPECTED_TEST_UBSAN_SUPPORTED}" + PARENT_SCOPE + ) +endfunction() + +function(_zeus_expected_enable_ubsan RESULT_VARIABLE) + set(${RESULT_VARIABLE} "" PARENT_SCOPE) + + if (NOT (ZEUS_EXPECTED_TEST_UBSAN_AUTO + OR ZEUS_EXPECTED_TEST_UBSAN_REQUIRED)) + message(STATUS "UndefinedBehaviorSanitizer disabled for tests") + return() + endif () + + if (CMAKE_CXX_COMPILER_ID MATCHES "^(AppleClang|Clang|GNU)$") + set(_zeus_expected_test_ubsan_compile_options + -fsanitize=undefined + -fno-sanitize-recover=undefined + -fno-omit-frame-pointer + ) + if (CMAKE_CXX_COMPILER_ID MATCHES "^(AppleClang|Clang)$") + list(APPEND _zeus_expected_test_ubsan_compile_options + -fno-sanitize-merge + ) + endif () + set(_zeus_expected_test_ubsan_link_options + -fsanitize=undefined + ) + + _zeus_expected_check_ubsan_support( + ZEUS_EXPECTED_TEST_UBSAN_SUPPORTED + ) + else () + set(ZEUS_EXPECTED_TEST_UBSAN_SUPPORTED FALSE) + endif () + + if (ZEUS_EXPECTED_TEST_UBSAN_SUPPORTED) + add_compile_options(${_zeus_expected_test_ubsan_compile_options}) + add_link_options(${_zeus_expected_test_ubsan_link_options}) + + set(${RESULT_VARIABLE} + "UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1" + PARENT_SCOPE + ) + message(STATUS "UndefinedBehaviorSanitizer enabled for tests") + elseif (ZEUS_EXPECTED_TEST_UBSAN_REQUIRED) + message(FATAL_ERROR + "ZEUS_EXPECTED_TEST_UBSAN_REQUIRED=ON, but the current compiler, " + "linker, or runtime environment cannot run " + "UndefinedBehaviorSanitizer-instrumented test executables" + ) + else () + message(STATUS + "UndefinedBehaviorSanitizer unavailable; tests will run without it" + ) + endif () +endfunction() + +function(zeus_expected_enable_test_sanitizers) + _zeus_expected_enable_asan(_asan_environment) + _zeus_expected_enable_ubsan(_ubsan_environment) + + set(_environment) + if (_asan_environment) + list(APPEND _environment "${_asan_environment}") + endif () + if (_ubsan_environment) + list(APPEND _environment "${_ubsan_environment}") + endif () + set(_ZEUS_EXPECTED_TEST_SANITIZER_ENVIRONMENT + "${_environment}" + PARENT_SCOPE + ) +endfunction() + +function(zeus_expected_configure_test_sanitizers TEST_NAME) + if (_ZEUS_EXPECTED_TEST_SANITIZER_ENVIRONMENT) + set_tests_properties("${TEST_NAME}" + PROPERTIES + ENVIRONMENT "${_ZEUS_EXPECTED_TEST_SANITIZER_ENVIRONMENT}" + ) + endif () +endfunction() + +function(zeus_expected_configure_catch_test_sanitizers TARGET_NAME) + if (NOT _ZEUS_EXPECTED_TEST_SANITIZER_ENVIRONMENT) + return() + endif () + + # Catch2 exposes the discovered test list only while CTest processes + # TEST_INCLUDE_FILES. Set the environment there so its semicolon-separated + # entries are not flattened as Catch2 property arguments. + set(_sanitizer_script + "${CMAKE_CURRENT_BINARY_DIR}/${TARGET_NAME}_sanitizer.cmake" + ) + string(CONCAT _sanitizer_script_content + "if(DEFINED ${TARGET_NAME}_TESTS)\n" + " set_tests_properties(\${${TARGET_NAME}_TESTS}\n" + " PROPERTIES ENVIRONMENT " + "[==[${_ZEUS_EXPECTED_TEST_SANITIZER_ENVIRONMENT}]==]\n" + " )\n" + "endif()\n" + ) + file(GENERATE + OUTPUT "${_sanitizer_script}" + CONTENT "${_sanitizer_script_content}" + ) + set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES + "${_sanitizer_script}" + ) +endfunction() diff --git a/tests/cmake/asan_default_options.cpp b/tests/cmake/asan_default_options.cpp new file mode 100644 index 0000000..c18f91a --- /dev/null +++ b/tests/cmake/asan_default_options.cpp @@ -0,0 +1,6 @@ +// Catch2 runs test executables during the build to discover test cases. Leak +// detection is enabled again by the ASAN_OPTIONS attached to the CTest tests. +extern "C" const char* __asan_default_options() +{ + return "detect_leaks=0"; +} diff --git a/tests/test_expected/CMakeLists.txt b/tests/test_expected/CMakeLists.txt index 3dc4efa..d100d0e 100644 --- a/tests/test_expected/CMakeLists.txt +++ b/tests/test_expected/CMakeLists.txt @@ -33,6 +33,7 @@ function(add_test_expected CPP_STANDARD) target_sources(${TARGET_NAME} PRIVATE ${SOURCES}) catch_discover_tests(${TARGET_NAME}) + zeus_expected_configure_catch_test_sanitizers(${TARGET_NAME}) endfunction() add_test_expected(17) diff --git a/tests/test_no_exceptions/CMakeLists.txt b/tests/test_no_exceptions/CMakeLists.txt index 454812f..9f902c1 100644 --- a/tests/test_no_exceptions/CMakeLists.txt +++ b/tests/test_no_exceptions/CMakeLists.txt @@ -11,3 +11,4 @@ else () endif () add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME}) +zeus_expected_configure_test_sanitizers(${PROJECT_NAME}) diff --git a/tests/third_party/msvc/msvc_stl_p0323r12_test/CMakeLists.txt b/tests/third_party/msvc/msvc_stl_p0323r12_test/CMakeLists.txt index 173b464..be58645 100644 --- a/tests/third_party/msvc/msvc_stl_p0323r12_test/CMakeLists.txt +++ b/tests/third_party/msvc/msvc_stl_p0323r12_test/CMakeLists.txt @@ -14,3 +14,4 @@ target_sources(${PROJECT_NAME} ) add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME}) +zeus_expected_configure_test_sanitizers(${PROJECT_NAME}) diff --git a/tests/third_party/msvc/msvc_stl_p2505r5_test/CMakeLists.txt b/tests/third_party/msvc/msvc_stl_p2505r5_test/CMakeLists.txt index f7c1498..b4367b3 100644 --- a/tests/third_party/msvc/msvc_stl_p2505r5_test/CMakeLists.txt +++ b/tests/third_party/msvc/msvc_stl_p2505r5_test/CMakeLists.txt @@ -14,3 +14,4 @@ target_sources(${PROJECT_NAME} ) add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME}) +zeus_expected_configure_test_sanitizers(${PROJECT_NAME}) From 421cd54db1788f75543ca07c1e0ca8153231583e Mon Sep 17 00:00:00 2001 From: X1aomu Date: Fri, 7 Aug 2026 15:00:30 +0800 Subject: [PATCH 08/24] test(msvc): match Catch2 STL annotations under ASan --- tests/cmake/ZeusExpectedTestSanitizers.cmake | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/cmake/ZeusExpectedTestSanitizers.cmake b/tests/cmake/ZeusExpectedTestSanitizers.cmake index 7101c02..c1cdc15 100644 --- a/tests/cmake/ZeusExpectedTestSanitizers.cmake +++ b/tests/cmake/ZeusExpectedTestSanitizers.cmake @@ -229,6 +229,16 @@ function(zeus_expected_configure_catch_test_sanitizers TARGET_NAME) return() endif () + if (MSVC AND _ZEUS_EXPECTED_TEST_ASAN_SUPPORTED) + # Conan may provide Catch2 as a static library built without ASan. + # Match its MSVC STL annotation mode while keeping the test target + # itself instrumented with /fsanitize=address. + target_compile_definitions("${TARGET_NAME}" PRIVATE + _DISABLE_STRING_ANNOTATION + _DISABLE_VECTOR_ANNOTATION + ) + endif () + # Catch2 exposes the discovered test list only while CTest processes # TEST_INCLUDE_FILES. Set the environment there so its semicolon-separated # entries are not flattened as Catch2 property arguments. From 10f1b386ac04217c1b13834e8362092e87c339c9 Mon Sep 17 00:00:00 2001 From: X1aomu Date: Thu, 6 Aug 2026 17:58:54 +0800 Subject: [PATCH 09/24] test: cover converting expected resource lifetime --- tests/test_expected/CMakeLists.txt | 1 + .../converting_constructor_tests.cpp | 72 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 tests/test_expected/converting_constructor_tests.cpp diff --git a/tests/test_expected/CMakeLists.txt b/tests/test_expected/CMakeLists.txt index d100d0e..47e09f5 100644 --- a/tests/test_expected/CMakeLists.txt +++ b/tests/test_expected/CMakeLists.txt @@ -2,6 +2,7 @@ project(test_expected LANGUAGES CXX) set(SOURCES base_tests.cpp + converting_constructor_tests.cpp monadic_tests.cpp noexcept_tests.cpp equality_tests.cpp diff --git a/tests/test_expected/converting_constructor_tests.cpp b/tests/test_expected/converting_constructor_tests.cpp new file mode 100644 index 0000000..ed79afa --- /dev/null +++ b/tests/test_expected/converting_constructor_tests.cpp @@ -0,0 +1,72 @@ +#include + +#include + +#include + +namespace +{ + +class ResourceOwningValue +{ +public: + ResourceOwningValue() + : resource_(new char[1]) + { + ++active_resources_; + } + + ~ResourceOwningValue() + { + if (resource_ != nullptr) + { + delete[] resource_; + --active_resources_; + } + } + + ResourceOwningValue(const ResourceOwningValue &) = delete; + ResourceOwningValue &operator=(const ResourceOwningValue &) = delete; + ResourceOwningValue &operator=(ResourceOwningValue &&) = delete; + + ResourceOwningValue(ResourceOwningValue &&other) noexcept + : resource_(std::exchange(other.resource_, nullptr)) + { + } + + static int active_resources() noexcept { return active_resources_; } + +private: + inline static int active_resources_ = 0; + char *resource_ = nullptr; +}; + +class ConvertibleValue +{ +public: + operator ResourceOwningValue() { return std::move(value_); } + +private: + ResourceOwningValue value_; +}; + +zeus::expected make_resource_owning_value() +{ + zeus::expected source = ConvertibleValue {}; + return source; +} + +} // namespace + +TEST_CASE("converting expected does not leak value resources", "[expected][converting-constructor][lifetime]") +{ + // Adapted from the reproducer in https://github.com/TartanLlama/expected/issues/180. + const int initial_active_resources = ResourceOwningValue::active_resources(); + + { + const auto result = make_resource_owning_value(); + REQUIRE(result.has_value()); + } + + REQUIRE(ResourceOwningValue::active_resources() == initial_active_resources); +} From b4678d4b7dd72f4565e4d31def5cc0aa858e3bc8 Mon Sep 17 00:00:00 2001 From: X1aomu Date: Mon, 10 Aug 2026 10:08:57 +0800 Subject: [PATCH 10/24] test: cover converting expected alternative construction --- .../converting_constructor_tests.cpp | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_expected/converting_constructor_tests.cpp b/tests/test_expected/converting_constructor_tests.cpp index ed79afa..b905165 100644 --- a/tests/test_expected/converting_constructor_tests.cpp +++ b/tests/test_expected/converting_constructor_tests.cpp @@ -50,6 +50,21 @@ class ConvertibleValue ResourceOwningValue value_; }; +class NonDefaultConstructibleValue +{ +public: + NonDefaultConstructibleValue() = delete; + NonDefaultConstructibleValue(int value) + : value_(value) + { + } + + int value() const noexcept { return value_; } + +private: + int value_; +}; + zeus::expected make_resource_owning_value() { zeus::expected source = ConvertibleValue {}; @@ -70,3 +85,29 @@ TEST_CASE("converting expected does not leak value resources", "[expected][conve REQUIRE(ResourceOwningValue::active_resources() == initial_active_resources); } + +TEST_CASE("converting expected does not require a default-constructible value", "[expected][converting-constructor]") +{ + const zeus::expected source = 42; + + const zeus::expected result = source; + + REQUIRE(result.has_value()); + REQUIRE(result->value() == 42); +} + +TEST_CASE("converting an error does not construct the value alternative", "[expected][converting-constructor][lifetime]") +{ + const int initial_active_resources = ResourceOwningValue::active_resources(); + + { + zeus::expected source(zeus::unexpect, 42); + const zeus::expected result(std::move(source)); + + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error() == 42); + REQUIRE(ResourceOwningValue::active_resources() == initial_active_resources); + } + + REQUIRE(ResourceOwningValue::active_resources() == initial_active_resources); +} From c77988281e5777e7dcdf82dfe849f398d2498b3f Mon Sep 17 00:00:00 2001 From: X1aomu Date: Thu, 6 Aug 2026 18:02:55 +0800 Subject: [PATCH 11/24] fix: avoid double construction in converting expected --- include/zeus/expected.hpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/include/zeus/expected.hpp b/include/zeus/expected.hpp index 5838d3e..5df0a6c 100644 --- a/include/zeus/expected.hpp +++ b/include/zeus/expected.hpp @@ -1290,7 +1290,8 @@ class expected constexpr expected( const expected &rhs ) noexcept(std::is_nothrow_constructible_v && std::is_nothrow_constructible_v) - : ctor_base(expected_detail::default_constructor_tag {}) + : impl_base(expected_detail::no_init) + , ctor_base(expected_detail::default_constructor_tag {}) { if (rhs.has_value()) { @@ -1313,7 +1314,8 @@ class expected constexpr explicit expected( const expected &rhs ) noexcept(std::is_nothrow_constructible_v && std::is_nothrow_constructible_v) - : ctor_base(expected_detail::default_constructor_tag {}) + : impl_base(expected_detail::no_init) + , ctor_base(expected_detail::default_constructor_tag {}) { if (rhs.has_value()) { @@ -1334,7 +1336,8 @@ class expected expected_detail::enable_from_other_expected_t * = nullptr > constexpr expected(expected &&rhs) noexcept(std::is_nothrow_constructible_v && std::is_nothrow_constructible_v) - : ctor_base(expected_detail::default_constructor_tag {}) + : impl_base(expected_detail::no_init) + , ctor_base(expected_detail::default_constructor_tag {}) { if (rhs.has_value()) { @@ -1357,7 +1360,8 @@ class expected constexpr explicit expected( expected &&rhs ) noexcept(std::is_nothrow_constructible_v && std::is_nothrow_constructible_v) - : ctor_base(expected_detail::default_constructor_tag {}) + : impl_base(expected_detail::no_init) + , ctor_base(expected_detail::default_constructor_tag {}) { if (rhs.has_value()) { From 5248f3b093659f98af633cc8ba985cfb23e24f4f Mon Sep 17 00:00:00 2001 From: X1aomu Date: Thu, 6 Aug 2026 18:40:38 +0800 Subject: [PATCH 12/24] test: cover constructor exception lifetimes --- tests/test_expected/CMakeLists.txt | 1 + .../constructor_exception_tests.cpp | 301 ++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 tests/test_expected/constructor_exception_tests.cpp diff --git a/tests/test_expected/CMakeLists.txt b/tests/test_expected/CMakeLists.txt index 47e09f5..e826826 100644 --- a/tests/test_expected/CMakeLists.txt +++ b/tests/test_expected/CMakeLists.txt @@ -3,6 +3,7 @@ project(test_expected LANGUAGES CXX) set(SOURCES base_tests.cpp converting_constructor_tests.cpp + constructor_exception_tests.cpp monadic_tests.cpp noexcept_tests.cpp equality_tests.cpp diff --git a/tests/test_expected/constructor_exception_tests.cpp b/tests/test_expected/constructor_exception_tests.cpp new file mode 100644 index 0000000..e678058 --- /dev/null +++ b/tests/test_expected/constructor_exception_tests.cpp @@ -0,0 +1,301 @@ +#include + +#include + +#include + +namespace +{ + +struct ConstructorException +{ +}; + +struct SourceValue +{ +}; + +struct SourceError +{ +}; + +template +class ErrorDestructorProbe +{ +public: + ErrorDestructorProbe() = default; + ErrorDestructorProbe(const SourceError &) {} + ErrorDestructorProbe(SourceError &&) {} + ErrorDestructorProbe(const ErrorDestructorProbe &) = default; + ErrorDestructorProbe(ErrorDestructorProbe &&) = default; + ErrorDestructorProbe &operator=(const ErrorDestructorProbe &) = default; + ErrorDestructorProbe &operator=(ErrorDestructorProbe &&) = default; + + ~ErrorDestructorProbe() { ++destructor_calls_; } + + static int destructor_calls() noexcept { return destructor_calls_; } + static void reset() noexcept { destructor_calls_ = 0; } + +private: + inline static int destructor_calls_ = 0; +}; + +class ImplicitThrowFromSourceValue +{ +public: + ImplicitThrowFromSourceValue(const SourceValue &) { throw ConstructorException {}; } + ImplicitThrowFromSourceValue(SourceValue &&) { throw ConstructorException {}; } +}; + +class ImplicitTargetValue +{ +public: + ImplicitTargetValue(const SourceValue &) {} + ImplicitTargetValue(SourceValue &&) {} +}; + +template +class ExplicitThrowFromSourceError +{ +public: + explicit ExplicitThrowFromSourceError(const SourceError &) { throw ConstructorException {}; } + explicit ExplicitThrowFromSourceError(SourceError &&) { throw ConstructorException {}; } + + ~ExplicitThrowFromSourceError() { ++destructor_calls_; } + + static int destructor_calls() noexcept { return destructor_calls_; } + static void reset() noexcept { destructor_calls_ = 0; } + +private: + inline static int destructor_calls_ = 0; +}; + +class ThrowOnCopyValue +{ +public: + ThrowOnCopyValue() = default; + ThrowOnCopyValue(const ThrowOnCopyValue &) { throw ConstructorException {}; } + ThrowOnCopyValue(ThrowOnCopyValue &&) = default; + ThrowOnCopyValue &operator=(const ThrowOnCopyValue &) = default; + ThrowOnCopyValue &operator=(ThrowOnCopyValue &&) = default; +}; + +class ThrowOnMoveValue +{ +public: + ThrowOnMoveValue() = default; + ThrowOnMoveValue(const ThrowOnMoveValue &) = delete; + ThrowOnMoveValue(ThrowOnMoveValue &&) { throw ConstructorException {}; } + ThrowOnMoveValue &operator=(const ThrowOnMoveValue &) = delete; + ThrowOnMoveValue &operator=(ThrowOnMoveValue &&) = default; +}; + +template +class ThrowOnCopyError +{ +public: + ThrowOnCopyError() = default; + ThrowOnCopyError(const ThrowOnCopyError &) { throw ConstructorException {}; } + ThrowOnCopyError(ThrowOnCopyError &&) = default; + ThrowOnCopyError &operator=(const ThrowOnCopyError &) = default; + ThrowOnCopyError &operator=(ThrowOnCopyError &&) = default; + + ~ThrowOnCopyError() { ++destructor_calls_; } + + static int destructor_calls() noexcept { return destructor_calls_; } + static void reset() noexcept { destructor_calls_ = 0; } + +private: + inline static int destructor_calls_ = 0; +}; + +template +class ThrowOnMoveError +{ +public: + ThrowOnMoveError() = default; + ThrowOnMoveError(const ThrowOnMoveError &) = delete; + ThrowOnMoveError(ThrowOnMoveError &&) { throw ConstructorException {}; } + ThrowOnMoveError &operator=(const ThrowOnMoveError &) = delete; + ThrowOnMoveError &operator=(ThrowOnMoveError &&) = default; + + ~ThrowOnMoveError() { ++destructor_calls_; } + + static int destructor_calls() noexcept { return destructor_calls_; } + static void reset() noexcept { destructor_calls_ = 0; } + +private: + inline static int destructor_calls_ = 0; +}; + +template +void require_no_destructor_for_unconstructed_error(Operation &&operation) +{ + ErrorProbe::reset(); + REQUIRE_THROWS_AS(std::forward(operation)(), ConstructorException); + REQUIRE(ErrorProbe::destructor_calls() == 0); +} + +} // namespace + +TEST_CASE( + "implicit lvalue converting constructor does not destroy an unconstructed error", "[expected][constructor][exception-safety][lifetime]" +) +{ + using Source = zeus::expected; + using Probe = ErrorDestructorProbe<1>; + using Target = zeus::expected; + + const Source source(std::in_place); + require_no_destructor_for_unconstructed_error( + [&] + { + Target target = source; + (void) target; + } + ); +} + +TEST_CASE( + "explicit lvalue converting constructor does not destroy an unconstructed error", "[expected][constructor][exception-safety][lifetime]" +) +{ + using Source = zeus::expected; + using Probe = ExplicitThrowFromSourceError<2>; + using Target = zeus::expected; + + const Source source(zeus::unexpect); + require_no_destructor_for_unconstructed_error( + [&] + { + Target target(source); + (void) target; + } + ); +} + +TEST_CASE( + "implicit rvalue converting constructor does not destroy an unconstructed error", "[expected][constructor][exception-safety][lifetime]" +) +{ + using Source = zeus::expected; + using Probe = ErrorDestructorProbe<3>; + using Target = zeus::expected; + + Source source(std::in_place); + require_no_destructor_for_unconstructed_error( + [&] + { + Target target = std::move(source); + (void) target; + } + ); +} + +TEST_CASE( + "explicit rvalue converting constructor does not destroy an unconstructed error", "[expected][constructor][exception-safety][lifetime]" +) +{ + using Source = zeus::expected; + using Probe = ExplicitThrowFromSourceError<4>; + using Target = zeus::expected; + + Source source(zeus::unexpect); + require_no_destructor_for_unconstructed_error( + [&] + { + Target target(std::move(source)); + (void) target; + } + ); +} + +TEST_CASE("copying a value does not destroy an unconstructed error", "[expected][constructor][exception-safety][lifetime]") +{ + using Probe = ErrorDestructorProbe<5>; + using Expected = zeus::expected; + + const Expected source(std::in_place); + require_no_destructor_for_unconstructed_error( + [&] + { + Expected target(source); + (void) target; + } + ); +} + +TEST_CASE("copying an error does not destroy an unconstructed error", "[expected][constructor][exception-safety][lifetime]") +{ + using Probe = ThrowOnCopyError<6>; + using Expected = zeus::expected; + + const Expected source(zeus::unexpect); + require_no_destructor_for_unconstructed_error( + [&] + { + Expected target(source); + (void) target; + } + ); +} + +TEST_CASE("moving a value does not destroy an unconstructed error", "[expected][constructor][exception-safety][lifetime]") +{ + using Probe = ErrorDestructorProbe<7>; + using Expected = zeus::expected; + + Expected source(std::in_place); + require_no_destructor_for_unconstructed_error( + [&] + { + Expected target(std::move(source)); + (void) target; + } + ); +} + +TEST_CASE("moving an error does not destroy an unconstructed error", "[expected][constructor][exception-safety][lifetime]") +{ + using Probe = ThrowOnMoveError<8>; + using Expected = zeus::expected; + + Expected source(zeus::unexpect); + require_no_destructor_for_unconstructed_error( + [&] + { + Expected target(std::move(source)); + (void) target; + } + ); +} + +TEST_CASE("copying a void expected does not destroy an unconstructed error", "[expected][constructor][exception-safety][lifetime]") +{ + using Probe = ThrowOnCopyError<9>; + using Expected = zeus::expected; + + const Expected source(zeus::unexpect); + require_no_destructor_for_unconstructed_error( + [&] + { + Expected target(source); + (void) target; + } + ); +} + +TEST_CASE("moving a void expected does not destroy an unconstructed error", "[expected][constructor][exception-safety][lifetime]") +{ + using Probe = ThrowOnMoveError<10>; + using Expected = zeus::expected; + + Expected source(zeus::unexpect); + require_no_destructor_for_unconstructed_error( + [&] + { + Expected target(std::move(source)); + (void) target; + } + ); +} From 1646473e94bb816a468cbcaa59d889c38856780d Mon Sep 17 00:00:00 2001 From: X1aomu Date: Thu, 6 Aug 2026 19:20:40 +0800 Subject: [PATCH 13/24] test: cover both alternatives on construction failure --- .../constructor_exception_tests.cpp | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_expected/constructor_exception_tests.cpp b/tests/test_expected/constructor_exception_tests.cpp index e678058..70889c7 100644 --- a/tests/test_expected/constructor_exception_tests.cpp +++ b/tests/test_expected/constructor_exception_tests.cpp @@ -19,6 +19,21 @@ struct SourceError { }; +template +class ThrowingValueDestructorProbe +{ +public: + ThrowingValueDestructorProbe(const SourceValue &) { throw ConstructorException {}; } + + ~ThrowingValueDestructorProbe() { ++destructor_calls_; } + + static int destructor_calls() noexcept { return destructor_calls_; } + static void reset() noexcept { destructor_calls_ = 0; } + +private: + inline static int destructor_calls_ = 0; +}; + template class ErrorDestructorProbe { @@ -138,6 +153,22 @@ void require_no_destructor_for_unconstructed_error(Operation &&operation) } // namespace +TEST_CASE("failed construction does not destroy either unconstructed alternative", "[expected][constructor][exception-safety][lifetime]") +{ + using Source = zeus::expected; + using ValueProbe = ThrowingValueDestructorProbe<0>; + using ErrorProbe = ErrorDestructorProbe<0>; + using Target = zeus::expected; + + const Source source(std::in_place); + ValueProbe::reset(); + ErrorProbe::reset(); + + REQUIRE_THROWS_AS(static_cast(Target(source)), ConstructorException); + REQUIRE(ValueProbe::destructor_calls() == 0); + REQUIRE(ErrorProbe::destructor_calls() == 0); +} + TEST_CASE( "implicit lvalue converting constructor does not destroy an unconstructed error", "[expected][constructor][exception-safety][lifetime]" ) From c9e97c81360ca84adbc243457c649c2f9c14c9e8 Mon Sep 17 00:00:00 2001 From: X1aomu Date: Mon, 10 Aug 2026 10:14:57 +0800 Subject: [PATCH 14/24] test: cover void expected conversion exceptions --- .../constructor_exception_tests.cpp | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/test_expected/constructor_exception_tests.cpp b/tests/test_expected/constructor_exception_tests.cpp index 70889c7..ae15537 100644 --- a/tests/test_expected/constructor_exception_tests.cpp +++ b/tests/test_expected/constructor_exception_tests.cpp @@ -69,6 +69,22 @@ class ImplicitTargetValue ImplicitTargetValue(SourceValue &&) {} }; +template +class ImplicitThrowFromSourceError +{ +public: + ImplicitThrowFromSourceError(const SourceError &) { throw ConstructorException {}; } + ImplicitThrowFromSourceError(SourceError &&) { throw ConstructorException {}; } + + ~ImplicitThrowFromSourceError() { ++destructor_calls_; } + + static int destructor_calls() noexcept { return destructor_calls_; } + static void reset() noexcept { destructor_calls_ = 0; } + +private: + inline static int destructor_calls_ = 0; +}; + template class ExplicitThrowFromSourceError { @@ -330,3 +346,79 @@ TEST_CASE("moving a void expected does not destroy an unconstructed error", "[ex } ); } + +TEST_CASE( + "implicit lvalue converting void expected does not destroy an unconstructed error", + "[expected][constructor][exception-safety][lifetime]" +) +{ + using Source = zeus::expected; + using Probe = ImplicitThrowFromSourceError<11>; + using Target = zeus::expected; + + const Source source(zeus::unexpect); + require_no_destructor_for_unconstructed_error( + [&] + { + Target target = source; + (void) target; + } + ); +} + +TEST_CASE( + "explicit lvalue converting void expected does not destroy an unconstructed error", + "[expected][constructor][exception-safety][lifetime]" +) +{ + using Source = zeus::expected; + using Probe = ExplicitThrowFromSourceError<12>; + using Target = zeus::expected; + + const Source source(zeus::unexpect); + require_no_destructor_for_unconstructed_error( + [&] + { + Target target(source); + (void) target; + } + ); +} + +TEST_CASE( + "implicit rvalue converting void expected does not destroy an unconstructed error", + "[expected][constructor][exception-safety][lifetime]" +) +{ + using Source = zeus::expected; + using Probe = ImplicitThrowFromSourceError<13>; + using Target = zeus::expected; + + Source source(zeus::unexpect); + require_no_destructor_for_unconstructed_error( + [&] + { + Target target = std::move(source); + (void) target; + } + ); +} + +TEST_CASE( + "explicit rvalue converting void expected does not destroy an unconstructed error", + "[expected][constructor][exception-safety][lifetime]" +) +{ + using Source = zeus::expected; + using Probe = ExplicitThrowFromSourceError<14>; + using Target = zeus::expected; + + Source source(zeus::unexpect); + require_no_destructor_for_unconstructed_error( + [&] + { + Target target(std::move(source)); + (void) target; + } + ); +} From f3048c17c2305b4288aac4e81570e978db28d863 Mon Sep 17 00:00:00 2001 From: X1aomu Date: Thu, 6 Aug 2026 19:21:14 +0800 Subject: [PATCH 15/24] fix: construct expected alternatives in storage --- include/zeus/expected.hpp | 215 +++++++++++++++++++------------------- 1 file changed, 110 insertions(+), 105 deletions(-) diff --git a/include/zeus/expected.hpp b/include/zeus/expected.hpp index 5df0a6c..c20410d 100644 --- a/include/zeus/expected.hpp +++ b/include/zeus/expected.hpp @@ -371,11 +371,52 @@ using enable_from_other_void_expected_t = std::enable_if_t< namespace expected_detail { -struct no_init_t +// Constructing the active alternative inside storage_base ensures its +// destructor only ever observes a fully initialized value or error. +struct construct_from_expected_t { - explicit no_init_t() = default; + explicit construct_from_expected_t() = default; }; -inline constexpr no_init_t no_init {}; +inline constexpr construct_from_expected_t construct_from_expected {}; + +template +constexpr bool expected_source_has_value(const Rhs &rhs) noexcept +{ + if constexpr (expected_detail::is_specialization_v, expected>) + { + return rhs.has_value(); + } + else + { + return rhs.m_has_val; + } +} + +template +constexpr decltype(auto) expected_source_value(Rhs &&rhs) noexcept +{ + if constexpr (expected_detail::is_specialization_v, expected>) + { + return *std::forward(rhs); + } + else + { + return (std::forward(rhs).m_val); + } +} + +template +constexpr decltype(auto) expected_source_error(Rhs &&rhs) noexcept +{ + if constexpr (expected_detail::is_specialization_v, expected>) + { + return std::forward(rhs).error(); + } + else + { + return (std::forward(rhs).m_unexpect); + } +} struct construct_with_invoke_result_t { @@ -458,10 +499,23 @@ struct storage_base , m_has_val(true) { } - constexpr storage_base(no_init_t) noexcept - : m_no_init() - , m_has_val(false) + + template + constexpr storage_base(construct_from_expected_t, Rhs &&rhs) noexcept( + std::is_nothrow_constructible_v()))> && + std::is_nothrow_constructible_v()))> + ) + : m_dummy() + , m_has_val(expected_detail::expected_source_has_value(rhs)) { + if (m_has_val) + { + expected_detail::construct_at(std::addressof(m_val), expected_detail::expected_source_value(std::forward(rhs))); + } + else + { + expected_detail::construct_at(std::addressof(m_unexpect), expected_detail::expected_source_error(std::forward(rhs))); + } } template> * = nullptr> @@ -522,7 +576,7 @@ struct storage_base { T m_val; E m_unexpect; - char m_no_init; + char m_dummy; }; bool m_has_val; }; @@ -540,10 +594,23 @@ struct storage_base , m_has_val(true) { } - constexpr storage_base(no_init_t) noexcept - : m_no_init() - , m_has_val(false) + + template + constexpr storage_base(construct_from_expected_t, Rhs &&rhs) noexcept( + std::is_nothrow_constructible_v()))> && + std::is_nothrow_constructible_v()))> + ) + : m_dummy() + , m_has_val(expected_detail::expected_source_has_value(rhs)) { + if (m_has_val) + { + expected_detail::construct_at(std::addressof(m_val), expected_detail::expected_source_value(std::forward(rhs))); + } + else + { + expected_detail::construct_at(std::addressof(m_unexpect), expected_detail::expected_source_error(std::forward(rhs))); + } } template> * = nullptr> @@ -622,7 +689,7 @@ struct storage_base { T m_val; E m_unexpect; - char m_no_init; + char m_dummy; }; bool m_has_val; }; @@ -641,10 +708,17 @@ struct storage_base { } - constexpr storage_base(no_init_t) noexcept + template + constexpr storage_base( + construct_from_expected_t, Rhs &&rhs + ) noexcept(std::is_nothrow_constructible_v()))>) : m_val() - , m_has_val(false) + , m_has_val(expected_detail::expected_source_has_value(rhs)) { + if (!m_has_val) + { + expected_detail::construct_at(std::addressof(m_unexpect), expected_detail::expected_source_error(std::forward(rhs))); + } } constexpr explicit storage_base(std::in_place_t) noexcept @@ -705,10 +779,17 @@ struct storage_base { } - constexpr storage_base(no_init_t) noexcept + template + constexpr storage_base( + construct_from_expected_t, Rhs &&rhs + ) noexcept(std::is_nothrow_constructible_v()))>) : m_val() - , m_has_val(false) + , m_has_val(expected_detail::expected_source_has_value(rhs)) { + if (!m_has_val) + { + expected_detail::construct_at(std::addressof(m_unexpect), expected_detail::expected_source_error(std::forward(rhs))); + } } constexpr explicit storage_base(std::in_place_t) noexcept @@ -871,16 +952,8 @@ struct copy_ctor_base : operations_base constexpr copy_ctor_base( const copy_ctor_base &rhs ) noexcept(is_nothrow_copy_constructible_or_void_v && std::is_nothrow_copy_constructible_v) - : operations_base(no_init) + : operations_base(construct_from_expected, rhs) { - if (rhs.m_has_val) - { - this->construct_with(rhs); - } - else - { - this->construct_error(rhs.geterr()); - } } copy_ctor_base(copy_ctor_base &&rhs) = default; @@ -927,16 +1000,8 @@ struct move_ctor_base : copy_ctor_base constexpr move_ctor_base( move_ctor_base &&rhs ) noexcept(is_nothrow_move_constructible_or_void_v && std::is_nothrow_move_constructible_v) - : copy_ctor_base(no_init) + : copy_ctor_base(construct_from_expected, std::move(rhs)) { - if (rhs.m_has_val) - { - this->construct_with(std::move(rhs)); - } - else - { - this->construct_error(std::move(rhs.geterr())); - } } move_ctor_base &operator=(const move_ctor_base &rhs) = default; @@ -1290,17 +1355,9 @@ class expected constexpr expected( const expected &rhs ) noexcept(std::is_nothrow_constructible_v && std::is_nothrow_constructible_v) - : impl_base(expected_detail::no_init) + : impl_base(expected_detail::construct_from_expected, rhs) , ctor_base(expected_detail::default_constructor_tag {}) { - if (rhs.has_value()) - { - this->construct(*rhs); - } - else - { - this->construct_error(rhs.error()); - } } // explicit const reference @@ -1314,17 +1371,9 @@ class expected constexpr explicit expected( const expected &rhs ) noexcept(std::is_nothrow_constructible_v && std::is_nothrow_constructible_v) - : impl_base(expected_detail::no_init) + : impl_base(expected_detail::construct_from_expected, rhs) , ctor_base(expected_detail::default_constructor_tag {}) { - if (rhs.has_value()) - { - this->construct(*rhs); - } - else - { - this->construct_error(rhs.error()); - } } // implicit rvalue @@ -1336,17 +1385,9 @@ class expected expected_detail::enable_from_other_expected_t * = nullptr > constexpr expected(expected &&rhs) noexcept(std::is_nothrow_constructible_v && std::is_nothrow_constructible_v) - : impl_base(expected_detail::no_init) + : impl_base(expected_detail::construct_from_expected, std::move(rhs)) , ctor_base(expected_detail::default_constructor_tag {}) { - if (rhs.has_value()) - { - this->construct(std::move(*rhs)); - } - else - { - this->construct_error(std::move(rhs.error())); - } } // explicit rvalue @@ -1360,17 +1401,9 @@ class expected constexpr explicit expected( expected &&rhs ) noexcept(std::is_nothrow_constructible_v && std::is_nothrow_constructible_v) - : impl_base(expected_detail::no_init) + : impl_base(expected_detail::construct_from_expected, std::move(rhs)) , ctor_base(expected_detail::default_constructor_tag {}) { - if (rhs.has_value()) - { - this->construct(std::move(*rhs)); - } - else - { - this->construct_error(std::move(rhs.error())); - } } // template @@ -2230,16 +2263,9 @@ class expected expected_detail::enable_from_other_void_expected_t * = nullptr > constexpr expected(const expected &rhs) noexcept(std::is_nothrow_constructible_v) - : ctor_base(expected_detail::default_constructor_tag {}) + : impl_base(expected_detail::construct_from_expected, rhs) + , ctor_base(expected_detail::default_constructor_tag {}) { - if (rhs.has_value()) - { - this->construct(); - } - else - { - this->construct_error(rhs.error()); - } } template< @@ -2250,16 +2276,9 @@ class expected expected_detail::enable_from_other_void_expected_t * = nullptr > constexpr explicit expected(const expected &rhs) noexcept(std::is_nothrow_constructible_v) - : ctor_base(expected_detail::default_constructor_tag {}) + : impl_base(expected_detail::construct_from_expected, rhs) + , ctor_base(expected_detail::default_constructor_tag {}) { - if (rhs.has_value()) - { - this->construct(); - } - else - { - this->construct_error(rhs.error()); - } } template< @@ -2270,16 +2289,9 @@ class expected expected_detail::enable_from_other_void_expected_t * = nullptr > constexpr expected(expected &&rhs) noexcept(std::is_nothrow_constructible_v) - : ctor_base(expected_detail::default_constructor_tag {}) + : impl_base(expected_detail::construct_from_expected, std::move(rhs)) + , ctor_base(expected_detail::default_constructor_tag {}) { - if (rhs.has_value()) - { - this->construct(); - } - else - { - this->construct_error(std::move(rhs.error())); - } } template< @@ -2290,16 +2302,9 @@ class expected expected_detail::enable_from_other_void_expected_t * = nullptr > constexpr explicit expected(expected &&rhs) noexcept(std::is_nothrow_constructible_v) - : ctor_base(expected_detail::default_constructor_tag {}) + : impl_base(expected_detail::construct_from_expected, std::move(rhs)) + , ctor_base(expected_detail::default_constructor_tag {}) { - if (rhs.has_value()) - { - this->construct(); - } - else - { - this->construct_error(std::move(rhs.error())); - } } // constructors for unexpected From 262e0bcbbb7ca59ccc6213390f4c7c4c472f8d31 Mon Sep 17 00:00:00 2001 From: X1aomu Date: Fri, 7 Aug 2026 15:20:00 +0800 Subject: [PATCH 16/24] fix(msvc): preserve source alternative value categories MSVC 19.29 and 19.44 mis-deduce the parenthesized member access in source_value/source_error when Rhs is an rvalue internal storage base and m_val/m_unexpect is inherited through dependent bases. They select an lvalue-reference return type and reject the return with C2440/C3487; C3615 follows in constexpr coverage. Per [expr.ref] and [dcl.type.decltype], member access on an xvalue object is an xvalue and decltype(auto) should deduce T&&/E&&. The pre-fix expression is conforming. The Linux validation baseline reports that GCC 15.3 and Clang 22.1.8 accept it, and clang-cl 22.1.8 accepts the minimized reproducer on the Windows MSVC target. Spell out the lvalue and rvalue cases, returning the named member for lvalues and std::move(member) for rvalues. This preserves cv/ref semantics without a compiler conditional while avoiding the MSVC frontend bug. Validated with v142/v143 across the Windows matrix and required ASan. Upstream tracking: no exact public MSVC issue was found during validation; a minimized reproducer is ready for filing. --- include/zeus/expected.hpp | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/include/zeus/expected.hpp b/include/zeus/expected.hpp index c20410d..253842c 100644 --- a/include/zeus/expected.hpp +++ b/include/zeus/expected.hpp @@ -401,7 +401,14 @@ constexpr decltype(auto) expected_source_value(Rhs &&rhs) noexcept } else { - return (std::forward(rhs).m_val); + if constexpr (std::is_lvalue_reference_v) + { + return (rhs.m_val); + } + else + { + return std::move(rhs.m_val); + } } } @@ -414,7 +421,14 @@ constexpr decltype(auto) expected_source_error(Rhs &&rhs) noexcept } else { - return (std::forward(rhs).m_unexpect); + if constexpr (std::is_lvalue_reference_v) + { + return (rhs.m_unexpect); + } + else + { + return std::move(rhs.m_unexpect); + } } } From 743cb0f75fae3589fbe527088fc2a25275bbac05 Mon Sep 17 00:00:00 2001 From: X1aomu Date: Mon, 10 Aug 2026 10:16:39 +0800 Subject: [PATCH 17/24] refactor: remove unused expected construction helpers --- include/zeus/expected.hpp | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/include/zeus/expected.hpp b/include/zeus/expected.hpp index 253842c..19ba858 100644 --- a/include/zeus/expected.hpp +++ b/include/zeus/expected.hpp @@ -865,27 +865,6 @@ struct operations_base : storage_base { using storage_base::storage_base; - template - constexpr void construct(Args &&...args) noexcept(std::is_nothrow_constructible_v) - { - expected_detail::construct_at(&this->m_val, std::forward(args)...); - this->m_has_val = true; - } - - template - constexpr void construct_with(Rhs &&rhs) noexcept(std::is_nothrow_constructible_v) - { - expected_detail::construct_at(&this->m_val, std::forward(rhs).get()); - this->m_has_val = true; - } - - template - constexpr void construct_error(Args &&...args) noexcept(std::is_nothrow_constructible_v) - { - expected_detail::construct_at(&this->m_unexpect, std::forward(args)...); - this->m_has_val = false; - } - constexpr T &get() &noexcept { return this->m_val; } constexpr const T &get() const &noexcept { return this->m_val; } constexpr T &&get() &&noexcept(std::is_nothrow_move_constructible_v) { return std::move(this->m_val); } @@ -904,23 +883,6 @@ struct operations_base : storage_base { using storage_base::storage_base; - constexpr void construct() noexcept { this->m_has_val = true; } - - // This function doesn't use its argument, but needs it so that code in - // levels above this can work independently of whether T is void - template - constexpr void construct_with(Rhs &&) noexcept - { - this->m_has_val = true; - } - - template - constexpr void construct_error(Args &&...args) noexcept(std::is_nothrow_constructible_v) - { - expected_detail::construct_at(&this->m_unexpect, std::forward(args)...); - this->m_has_val = false; - } - constexpr E &geterr() &noexcept { return this->m_unexpect; } constexpr const E &geterr() const &noexcept { return this->m_unexpect; } constexpr E &&geterr() &&noexcept(std::is_nothrow_move_constructible_v) { return std::move(this->m_unexpect); } From ac5c55ce527f86582639c0187907eb145ff0c6e3 Mon Sep 17 00:00:00 2001 From: X1aomu Date: Mon, 10 Aug 2026 14:14:15 +0800 Subject: [PATCH 18/24] test: cover assignment exception rollback --- tests/test_expected/CMakeLists.txt | 1 + .../assignment_exception_tests.cpp | 304 ++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 tests/test_expected/assignment_exception_tests.cpp diff --git a/tests/test_expected/CMakeLists.txt b/tests/test_expected/CMakeLists.txt index e826826..7899579 100644 --- a/tests/test_expected/CMakeLists.txt +++ b/tests/test_expected/CMakeLists.txt @@ -1,6 +1,7 @@ project(test_expected LANGUAGES CXX) set(SOURCES + assignment_exception_tests.cpp base_tests.cpp converting_constructor_tests.cpp constructor_exception_tests.cpp diff --git a/tests/test_expected/assignment_exception_tests.cpp b/tests/test_expected/assignment_exception_tests.cpp new file mode 100644 index 0000000..eec818a --- /dev/null +++ b/tests/test_expected/assignment_exception_tests.cpp @@ -0,0 +1,304 @@ +#include +#include + +#include + +#include + +namespace +{ + +struct ConstructionException +{ +}; + +template +class NothrowMoveState +{ +public: + explicit NothrowMoveState(int payload = 0) + : payload_(payload) + { + ++live_instances_; + } + + NothrowMoveState(const NothrowMoveState &other) noexcept + : payload_(other.payload_) + { + ++live_instances_; + } + + NothrowMoveState(NothrowMoveState &&other) noexcept + : payload_(std::exchange(other.payload_, -1)) + { + ++live_instances_; + } + + NothrowMoveState &operator=(const NothrowMoveState &other) noexcept + { + payload_ = other.payload_; + return *this; + } + + NothrowMoveState &operator=(NothrowMoveState &&other) noexcept + { + payload_ = std::exchange(other.payload_, -1); + return *this; + } + + ~NothrowMoveState() { --live_instances_; } + + int payload() const noexcept { return payload_; } + static int live_instances() noexcept { return live_instances_; } + +private: + inline static int live_instances_ = 0; + int payload_ = 0; +}; + +class ThrowOnCopy +{ +public: + explicit ThrowOnCopy(int payload = 0) + : payload_(payload) + { + } + + ThrowOnCopy(const ThrowOnCopy &) { throw ConstructionException {}; } + ThrowOnCopy(ThrowOnCopy &&) noexcept = default; + ThrowOnCopy &operator=(const ThrowOnCopy &) = default; + ThrowOnCopy &operator=(ThrowOnCopy &&) noexcept = default; + + int payload() const noexcept { return payload_; } + +private: + int payload_ = 0; +}; + +class ThrowOnMove +{ +public: + explicit ThrowOnMove(int payload = 0) + : payload_(payload) + { + } + + ThrowOnMove(const ThrowOnMove &) = delete; + ThrowOnMove &operator=(const ThrowOnMove &) = delete; + ThrowOnMove(ThrowOnMove &&) { throw ConstructionException {}; } + ThrowOnMove &operator=(ThrowOnMove &&) = default; + + int payload() const noexcept { return payload_; } + +private: + int payload_ = 0; +}; + +struct ValueSource +{ +}; + +class ThrowOnValueConversion +{ +public: + ThrowOnValueConversion(const ValueSource &) { throw ConstructionException {}; } + ThrowOnValueConversion(ThrowOnValueConversion &&) noexcept(false) {} + ThrowOnValueConversion &operator=(const ValueSource &) { return *this; } +}; + +struct ErrorSource +{ +}; + +class ThrowOnErrorConversion +{ +public: + ThrowOnErrorConversion(const ErrorSource &) { throw ConstructionException {}; } + ThrowOnErrorConversion(ThrowOnErrorConversion &&) noexcept(false) {} + ThrowOnErrorConversion &operator=(const ErrorSource &) { return *this; } +}; + +} // namespace + +TEST_CASE("copy assignment preserves an error when value construction throws", "[expected][assignment][exception-safety]") +{ + using Error = NothrowMoveState<0>; + using Expected = zeus::expected; + + STATIC_REQUIRE_FALSE(std::is_nothrow_copy_constructible_v); + STATIC_REQUIRE(std::is_nothrow_move_constructible_v); + + const int initial_live_errors = Error::live_instances(); + + { + Expected target(zeus::unexpect, 42); + const Expected source(std::in_place, 7); + + REQUIRE_THROWS_AS(target = source, ConstructionException); + REQUIRE_FALSE(target.has_value()); + REQUIRE(target.error().payload() == 42); + REQUIRE(source.has_value()); + REQUIRE(source->payload() == 7); + REQUIRE(Error::live_instances() == initial_live_errors + 1); + } + + REQUIRE(Error::live_instances() == initial_live_errors); +} + +TEST_CASE("copy assignment preserves a value when error construction throws", "[expected][assignment][exception-safety]") +{ + using Value = NothrowMoveState<1>; + using Expected = zeus::expected; + + STATIC_REQUIRE_FALSE(std::is_nothrow_copy_constructible_v); + STATIC_REQUIRE(std::is_nothrow_move_constructible_v); + + const int initial_live_values = Value::live_instances(); + + { + Expected target(std::in_place, 42); + const Expected source(zeus::unexpect, 7); + + REQUIRE_THROWS_AS(target = source, ConstructionException); + REQUIRE(target.has_value()); + REQUIRE(target->payload() == 42); + REQUIRE_FALSE(source.has_value()); + REQUIRE(source.error().payload() == 7); + REQUIRE(Value::live_instances() == initial_live_values + 1); + } + + REQUIRE(Value::live_instances() == initial_live_values); +} + +TEST_CASE("move assignment restores an error when value construction throws", "[expected][assignment][exception-safety]") +{ + using Error = NothrowMoveState<2>; + using Expected = zeus::expected; + + STATIC_REQUIRE_FALSE(std::is_nothrow_move_constructible_v); + STATIC_REQUIRE(std::is_nothrow_move_constructible_v); + + const int initial_live_errors = Error::live_instances(); + + { + Expected target(zeus::unexpect, 42); + Expected source(std::in_place, 7); + + REQUIRE_THROWS_AS(target = std::move(source), ConstructionException); + REQUIRE_FALSE(target.has_value()); + REQUIRE(target.error().payload() == 42); + REQUIRE(source.has_value()); + REQUIRE(source->payload() == 7); + REQUIRE(Error::live_instances() == initial_live_errors + 1); + } + + REQUIRE(Error::live_instances() == initial_live_errors); +} + +TEST_CASE("move assignment restores a value when error construction throws", "[expected][assignment][exception-safety]") +{ + using Value = NothrowMoveState<3>; + using Expected = zeus::expected; + + STATIC_REQUIRE(std::is_nothrow_move_constructible_v); + STATIC_REQUIRE_FALSE(std::is_nothrow_move_constructible_v); + + const int initial_live_values = Value::live_instances(); + + { + Expected target(std::in_place, 42); + Expected source(zeus::unexpect, 7); + + REQUIRE_THROWS_AS(target = std::move(source), ConstructionException); + REQUIRE(target.has_value()); + REQUIRE(target->payload() == 42); + REQUIRE_FALSE(source.has_value()); + REQUIRE(source.error().payload() == 7); + REQUIRE(Value::live_instances() == initial_live_values + 1); + } + + REQUIRE(Value::live_instances() == initial_live_values); +} + +TEST_CASE("value assignment restores an error when conversion throws", "[expected][assignment][exception-safety]") +{ + using Error = NothrowMoveState<4>; + using Expected = zeus::expected; + + STATIC_REQUIRE_FALSE(std::is_nothrow_constructible_v); + STATIC_REQUIRE_FALSE(std::is_nothrow_move_constructible_v); + STATIC_REQUIRE(std::is_nothrow_move_constructible_v); + + const int initial_live_errors = Error::live_instances(); + + { + Expected target(zeus::unexpect, 42); + const ValueSource source; + + REQUIRE_THROWS_AS(target = source, ConstructionException); + REQUIRE_FALSE(target.has_value()); + REQUIRE(target.error().payload() == 42); + REQUIRE(Error::live_instances() == initial_live_errors + 1); + } + + REQUIRE(Error::live_instances() == initial_live_errors); +} + +TEST_CASE("unexpected assignment restores a value when conversion throws", "[expected][assignment][exception-safety]") +{ + using Value = NothrowMoveState<5>; + using Expected = zeus::expected; + + STATIC_REQUIRE(std::is_nothrow_move_constructible_v); + STATIC_REQUIRE_FALSE(std::is_nothrow_constructible_v); + STATIC_REQUIRE_FALSE(std::is_nothrow_move_constructible_v); + + const int initial_live_values = Value::live_instances(); + + { + Expected target(std::in_place, 42); + const zeus::unexpected source(ErrorSource {}); + + REQUIRE_THROWS_AS(target = source, ConstructionException); + REQUIRE(target.has_value()); + REQUIRE(target->payload() == 42); + REQUIRE(Value::live_instances() == initial_live_values + 1); + } + + REQUIRE(Value::live_instances() == initial_live_values); +} + +TEST_CASE("copy assignment of a void expected retains a value when error construction throws", "[expected][assignment][exception-safety]") +{ + using Expected = zeus::expected; + + Expected target; + const Expected source(zeus::unexpect, 7); + + REQUIRE_THROWS_AS(target = source, ConstructionException); + REQUIRE(target.has_value()); + REQUIRE_FALSE(source.has_value()); + REQUIRE(source.error().payload() == 7); +} + +TEST_CASE("move assignment of a void expected retains a value when error construction throws", "[expected][assignment][exception-safety]") +{ + using Expected = zeus::expected; + + Expected target; + Expected source(zeus::unexpect, 7); + + REQUIRE_THROWS_AS(target = std::move(source), ConstructionException); + REQUIRE(target.has_value()); + REQUIRE_FALSE(source.has_value()); + REQUIRE(source.error().payload() == 7); +} + +TEST_CASE("unexpected assignment of a void expected retains a value when conversion throws", "[expected][assignment][exception-safety]") +{ + zeus::expected target; + const zeus::unexpected source(ErrorSource {}); + + REQUIRE_THROWS_AS(target = source, ConstructionException); + REQUIRE(target.has_value()); +} From 4bd9a6bedcd903944a905174ab6336a828f7af6f Mon Sep 17 00:00:00 2001 From: X1aomu Date: Mon, 10 Aug 2026 14:16:33 +0800 Subject: [PATCH 19/24] test: cover swap exception rollback --- tests/test_expected/CMakeLists.txt | 1 + tests/test_expected/swap_exception_tests.cpp | 206 +++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 tests/test_expected/swap_exception_tests.cpp diff --git a/tests/test_expected/CMakeLists.txt b/tests/test_expected/CMakeLists.txt index 7899579..4270866 100644 --- a/tests/test_expected/CMakeLists.txt +++ b/tests/test_expected/CMakeLists.txt @@ -7,6 +7,7 @@ set(SOURCES constructor_exception_tests.cpp monadic_tests.cpp noexcept_tests.cpp + swap_exception_tests.cpp equality_tests.cpp equality_noexcept_tests.cpp p3379_tests.cpp diff --git a/tests/test_expected/swap_exception_tests.cpp b/tests/test_expected/swap_exception_tests.cpp new file mode 100644 index 0000000..837b370 --- /dev/null +++ b/tests/test_expected/swap_exception_tests.cpp @@ -0,0 +1,206 @@ +#include +#include + +#include + +#include + +namespace +{ + +struct ConstructionException +{ +}; + +template +class NothrowMoveState +{ +public: + explicit NothrowMoveState(int payload = 0) + : payload_(payload) + { + ++live_instances_; + } + + NothrowMoveState(const NothrowMoveState &other) noexcept + : payload_(other.payload_) + { + ++live_instances_; + } + + NothrowMoveState(NothrowMoveState &&other) noexcept + : payload_(std::exchange(other.payload_, -1)) + { + ++live_instances_; + } + + NothrowMoveState &operator=(const NothrowMoveState &other) noexcept + { + payload_ = other.payload_; + return *this; + } + + NothrowMoveState &operator=(NothrowMoveState &&other) noexcept + { + payload_ = std::exchange(other.payload_, -1); + return *this; + } + + ~NothrowMoveState() { --live_instances_; } + + friend void swap(NothrowMoveState &lhs, NothrowMoveState &rhs) noexcept + { + using std::swap; + swap(lhs.payload_, rhs.payload_); + } + + int payload() const noexcept { return payload_; } + static int live_instances() noexcept { return live_instances_; } + +private: + inline static int live_instances_ = 0; + int payload_ = 0; +}; + +template +class ThrowOnMoveState +{ +public: + explicit ThrowOnMoveState(int payload = 0) + : payload_(payload) + { + ++live_instances_; + } + + ThrowOnMoveState(const ThrowOnMoveState &) = delete; + ThrowOnMoveState &operator=(const ThrowOnMoveState &) = delete; + + ThrowOnMoveState(ThrowOnMoveState &&other) + : payload_(other.payload_) + { + throw ConstructionException {}; + } + + ThrowOnMoveState &operator=(ThrowOnMoveState &&other) noexcept + { + payload_ = std::exchange(other.payload_, -1); + return *this; + } + + ~ThrowOnMoveState() { --live_instances_; } + + friend void swap(ThrowOnMoveState &lhs, ThrowOnMoveState &rhs) noexcept + { + using std::swap; + swap(lhs.payload_, rhs.payload_); + } + + int payload() const noexcept { return payload_; } + static int live_instances() noexcept { return live_instances_; } + +private: + inline static int live_instances_ = 0; + int payload_ = 0; +}; + +} // namespace + +TEST_CASE("swap restores an error when value construction throws", "[expected][swap][exception-safety]") +{ + using Value = ThrowOnMoveState<0>; + using Error = NothrowMoveState<0>; + using Expected = zeus::expected; + + STATIC_REQUIRE_FALSE(std::is_nothrow_move_constructible_v); + STATIC_REQUIRE(std::is_nothrow_move_constructible_v); + + const int initial_live_values = Value::live_instances(); + const int initial_live_errors = Error::live_instances(); + + { + Expected value(std::in_place, 17); + Expected error(zeus::unexpect, 42); + + REQUIRE_THROWS_AS(value.swap(error), ConstructionException); + REQUIRE(value.has_value()); + REQUIRE(value->payload() == 17); + REQUIRE_FALSE(error.has_value()); + REQUIRE(error.error().payload() == 42); + REQUIRE(Value::live_instances() == initial_live_values + 1); + REQUIRE(Error::live_instances() == initial_live_errors + 1); + } + + REQUIRE(Value::live_instances() == initial_live_values); + REQUIRE(Error::live_instances() == initial_live_errors); +} + +TEST_CASE("swap restores a value when error construction throws", "[expected][swap][exception-safety]") +{ + using Value = NothrowMoveState<1>; + using Error = ThrowOnMoveState<1>; + using Expected = zeus::expected; + + STATIC_REQUIRE(std::is_nothrow_move_constructible_v); + STATIC_REQUIRE_FALSE(std::is_nothrow_move_constructible_v); + + const int initial_live_values = Value::live_instances(); + const int initial_live_errors = Error::live_instances(); + + { + Expected value(std::in_place, 17); + Expected error(zeus::unexpect, 42); + + REQUIRE_THROWS_AS(value.swap(error), ConstructionException); + REQUIRE(value.has_value()); + REQUIRE(value->payload() == 17); + REQUIRE_FALSE(error.has_value()); + REQUIRE(error.error().payload() == 42); + REQUIRE(Value::live_instances() == initial_live_values + 1); + REQUIRE(Error::live_instances() == initial_live_errors + 1); + } + + REQUIRE(Value::live_instances() == initial_live_values); + REQUIRE(Error::live_instances() == initial_live_errors); +} + +TEST_CASE("void swap retains states when moving an error into the left operand throws", "[expected][swap][exception-safety]") +{ + using Error = ThrowOnMoveState<2>; + using Expected = zeus::expected; + + const int initial_live_errors = Error::live_instances(); + + { + Expected value; + Expected error(zeus::unexpect, 42); + + REQUIRE_THROWS_AS(value.swap(error), ConstructionException); + REQUIRE(value.has_value()); + REQUIRE_FALSE(error.has_value()); + REQUIRE(error.error().payload() == 42); + REQUIRE(Error::live_instances() == initial_live_errors + 1); + } + + REQUIRE(Error::live_instances() == initial_live_errors); +} + +TEST_CASE("void swap retains states when moving an error into the right operand throws", "[expected][swap][exception-safety]") +{ + using Error = ThrowOnMoveState<3>; + using Expected = zeus::expected; + + const int initial_live_errors = Error::live_instances(); + + { + Expected error(zeus::unexpect, 42); + Expected value; + + REQUIRE_THROWS_AS(error.swap(value), ConstructionException); + REQUIRE_FALSE(error.has_value()); + REQUIRE(error.error().payload() == 42); + REQUIRE(value.has_value()); + REQUIRE(Error::live_instances() == initial_live_errors + 1); + } + + REQUIRE(Error::live_instances() == initial_live_errors); +} From bb1cc3ef60388d054dba1166b6e77e2dfd76435f Mon Sep 17 00:00:00 2001 From: X1aomu Date: Fri, 7 Aug 2026 17:05:10 +0800 Subject: [PATCH 20/24] ci: update checkout action to v7 Use the current checkout release to avoid warnings from deprecated JavaScript action runtimes. --- .github/workflows/tests.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 92bf7b9..00a6a1b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,7 +37,7 @@ jobs: runs-on: windows-2022 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install Conan uses: turtlebrowser/get-conan@main @@ -99,7 +99,7 @@ jobs: runs-on: ${{ matrix.gcc.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup GCC if: matrix.gcc.version == 15 @@ -185,7 +185,7 @@ jobs: python3-pip \ unzip - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Check GCC Version run: | @@ -252,7 +252,7 @@ jobs: CXX: clang++-${{ matrix.clang-version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install Clang uses: egor-tensin/setup-clang@v2 @@ -326,7 +326,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup GCC uses: egor-tensin/setup-gcc@v1 From 303dd726afe9d4e3afb859a1aaf910c70c1132d3 Mon Sep 17 00:00:00 2001 From: X1aomu Date: Fri, 7 Aug 2026 17:07:24 +0800 Subject: [PATCH 21/24] ci: disable automatic sanitizers in regular tests Keep the broad compiler matrix deterministic and reserve required sanitizer coverage for the dedicated sanitizer workflow. --- .github/workflows/tests.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 00a6a1b..2d4324a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -62,7 +62,7 @@ jobs: - name: CMake Configure run: | cmake --list-presets - cmake --preset conan-default + cmake --preset conan-default -DZEUS_EXPECTED_TEST_ASAN_AUTO=OFF -DZEUS_EXPECTED_TEST_UBSAN_AUTO=OFF - name: Build run: | @@ -134,7 +134,7 @@ jobs: - name: CMake Configure run: | cmake --list-presets - cmake --preset ${{ matrix.cmake-preset }} + cmake --preset ${{ matrix.cmake-preset }} -DZEUS_EXPECTED_TEST_ASAN_AUTO=OFF -DZEUS_EXPECTED_TEST_UBSAN_AUTO=OFF - name: Build run: | @@ -214,7 +214,7 @@ jobs: - name: CMake Configure run: | cmake --list-presets - cmake --preset ${{ matrix.cmake-preset }} + cmake --preset ${{ matrix.cmake-preset }} -DZEUS_EXPECTED_TEST_ASAN_AUTO=OFF -DZEUS_EXPECTED_TEST_UBSAN_AUTO=OFF - name: Build run: | @@ -294,7 +294,7 @@ jobs: - name: CMake Configure run: | cmake --list-presets - cmake --preset ${{ matrix.cmake-preset }} + cmake --preset ${{ matrix.cmake-preset }} -DZEUS_EXPECTED_TEST_ASAN_AUTO=OFF -DZEUS_EXPECTED_TEST_UBSAN_AUTO=OFF - name: Build run: | @@ -360,7 +360,7 @@ jobs: - name: CMake Configure run: | cmake --list-presets - cmake --preset ${{ matrix.cmake-preset }} + cmake --preset ${{ matrix.cmake-preset }} -DZEUS_EXPECTED_TEST_ASAN_AUTO=OFF -DZEUS_EXPECTED_TEST_UBSAN_AUTO=OFF - name: Build run: | From 2e202b808c4de6182f389706fecec2e7a2a07ab3 Mon Sep 17 00:00:00 2001 From: X1aomu Date: Fri, 7 Aug 2026 17:21:56 +0800 Subject: [PATCH 22/24] ci: add sanitizer test matrix --- .github/workflows/sanitizers.yml | 146 +++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 .github/workflows/sanitizers.yml diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml new file mode 100644 index 0000000..5b5bf9d --- /dev/null +++ b/.github/workflows/sanitizers.yml @@ -0,0 +1,146 @@ +name: Sanitizers + +on: + workflow_dispatch: + push: + branches: + - main + - ci/** + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + test-on-msvc: + strategy: + fail-fast: false + + matrix: + build-type: [Debug, Release] + include: + - build-type: Debug + cmake-preset: conan-debug + - build-type: Release + cmake-preset: conan-release + + name: MSVC v143 - ASan - ${{ matrix.build-type }} + + runs-on: windows-2022 + + steps: + - uses: actions/checkout@v7 + + - name: Setup MSVC developer environment + uses: TheMrMilchmann/setup-msvc-dev@v4 + with: + arch: x64 + + - name: Install Conan + uses: turtlebrowser/get-conan@main + + - name: Setup Conan + run: | + conan --version + conan profile detect + + - name: Install CMake + uses: lukka/get-cmake@latest + + - name: Install dependencies + run: > + conan install . + -c tools.microsoft.msbuild:vs_version=17 + -s compiler.version=194 + -s compiler.cppstd=23 + -s build_type=${{ matrix.build-type }} + -b missing + + - name: CMake Configure + run: > + cmake --preset conan-default + -DZEUS_EXPECTED_TEST_ASAN_REQUIRED=ON + + - name: Build + run: cmake --build --preset ${{ matrix.cmake-preset }} + + - name: Test + run: ctest --preset ${{ matrix.cmake-preset }} --output-on-failure + + test-on-linux: + strategy: + fail-fast: false + + matrix: + compiler: + - name: GCC 15 + id: gcc + version: 15 + cc: gcc-15 + cxx: g++-15 + - name: Clang 22 + id: clang + version: 22 + cc: clang-22 + cxx: clang++-22 + build-type: [Debug, Release] + include: + - build-type: Debug + cmake-preset: conan-debug + - build-type: Release + cmake-preset: conan-release + + name: ${{ matrix.compiler.name }} - ASan + UBSan - ${{ matrix.build-type }} + + runs-on: ubuntu-24.04 + + env: + CC: ${{ matrix.compiler.cc }} + CXX: ${{ matrix.compiler.cxx }} + + steps: + - uses: actions/checkout@v7 + + - name: Setup GCC + if: matrix.compiler.id == 'gcc' + uses: egor-tensin/setup-gcc@v2 + with: + version: ${{ matrix.compiler.version }} + + - name: Setup Clang + if: matrix.compiler.id == 'clang' + uses: egor-tensin/setup-clang@v2 + with: + version: ${{ matrix.compiler.version }} + + - name: Install Conan + uses: turtlebrowser/get-conan@main + + - name: Setup Conan + run: | + conan --version + conan profile detect --force + + - name: Install CMake + uses: lukka/get-cmake@latest + + - name: Install dependencies + run: > + conan install . + -s compiler.cppstd=23 + -s build_type=${{ matrix.build-type }} + -b missing + + - name: CMake Configure + run: > + cmake --preset ${{ matrix.cmake-preset }} + -DZEUS_EXPECTED_TEST_ASAN_REQUIRED=ON + -DZEUS_EXPECTED_TEST_UBSAN_REQUIRED=ON + + - name: Build + run: cmake --build --preset ${{ matrix.cmake-preset }} + + - name: Test + run: ctest --preset ${{ matrix.cmake-preset }} --output-on-failure From e4fa521d5bf036ce521d6895a30e5120e6c87e20 Mon Sep 17 00:00:00 2001 From: X1aomu Date: Mon, 10 Aug 2026 14:51:26 +0800 Subject: [PATCH 23/24] doc: update compiler support table --- README.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3547086..c3f822b 100644 --- a/README.md +++ b/README.md @@ -32,18 +32,17 @@ Enhancements: + Enhanced noexcept (covered by tests from MSVC's STL) -## Compiler supports +## Compiler support -Any compiler that supports C++17 should work. +A conforming C++17 implementation is required. Higher language standards are also supported and can provide benefits such as enhanced constexpr capabilities. -Higher language standards are also supported, which can provide benefits such as enhanced constexpr capabilities. +| Compiler | Supported versions | +| --- | --- | +| MSVC | v142 and later | +| GCC | 8 and later | +| Clang | 11 and later | -List of known compiler supported: - -+ MSVC v142 and later -+ GCC 8 and later - -Feedbacks are welcome. +See the [CI workflow](.github/workflows/tests.yml) for the compiler and language standard combinations that are continuously tested. Other conforming compilers may also work. Feedback is welcome. ## Building and testing From 71360d83487993a19223577eb7a31e5af2808106 Mon Sep 17 00:00:00 2001 From: X1aomu Date: Mon, 10 Aug 2026 14:58:36 +0800 Subject: [PATCH 24/24] doc: update project description --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c3f822b..375c414 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![GitHub Release](https://img.shields.io/github/v/release/zeus-cpp/expected?color=green)](https://github.com/zeus-cpp/expected/releases) [![Conan Center](https://img.shields.io/conan/v/zeus_expected)](https://conan.io/center/recipes/zeus_expected) -Backporting `std::expected` to C++17. +A C++17 backport of `std::expected`, designed for API compatibility with the standard library. ## Features