From 53ced3f9f58f6dd0db3674e5cd0baffd0918dc93 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leroy Date: Tue, 1 Sep 2026 17:14:04 -0400 Subject: [PATCH 1/2] initialize: keep the previous dispatch state when a policy throws write_global_data() patched every class' static_vptr, every overrider's `next` and each method's slots and strides to point into a local vector, then ran the policies' initialize, and only then swapped the vector into the registry's state. A policy that threw - fast_perfect_hash failing to find hash factors under throw_error_handler, or a plain bad_alloc - left all of those pointers dangling into a vector that unwinding had just freed, and clobbered the dispatch state of the previous initialize. Reorder it into stage, policies, commit. The dispatch data is built in a local vector and each class' v-table pointer is staged in its class_, where the policies read it; the policies run with their states saved in an RAII transaction that puts them back if one throws; the shared locations are written only after that, and nothing on that path can throw. A failed initialize() - a re-initialize after dlopen/dlclose included - leaves the previous tables in place, complete and consistent, though not marked initialized. The InitializeClass blueprint changes accordingly: vptr() returns the staged pointer by value, and static_vptr() returns the address that will receive it, for the policies that store pointers to v-table pointers. Fixes #81. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012QA4PCHE4oooe1ZtG9aKj7 --- .../ROOT/pages/registries_and_policies.adoc | 8 + include/boost/openmethod/initialize.hpp | 219 +++++++++++++---- .../boost/openmethod/policies/vptr_map.hpp | 2 +- .../boost/openmethod/policies/vptr_vector.hpp | 2 +- include/boost/openmethod/preamble.hpp | 28 ++- test/test_initialize_transaction.cpp | 221 ++++++++++++++++++ 6 files changed, 430 insertions(+), 50 deletions(-) create mode 100644 test/test_initialize_transaction.cpp diff --git a/doc/modules/ROOT/pages/registries_and_policies.adoc b/doc/modules/ROOT/pages/registries_and_policies.adoc index 01d158b5..7f9085b8 100644 --- a/doc/modules/ROOT/pages/registries_and_policies.adoc +++ b/doc/modules/ROOT/pages/registries_and_policies.adoc @@ -163,6 +163,14 @@ the state of the `type_hash` policy, so the `type_hash` policy must come before precedes `vptr_vector`. This particular requirement is enforced with a `static_assert`. +cpp:initialize[] is transactional. If a policy's `initialize` throws - +`fast_perfect_hash` failing to find hash factors under `throw_error_handler`, +say - every policy gets its previous state back, and nothing else in the +registry is modified: the v-table pointers, `next` pointers and dispatch +tables from the previous call all stay in place. The registry is marked as not +initialized, though, since that state no longer reflects the registrations, +and cpp:initialize[] must be called again before calling a method. + A registry can also be created by copying an existing registry's policies, using the cpp:with[] and cpp:without[] nested templates. For example, `indirect_registry` is a tweak of `default_registry`: diff --git a/include/boost/openmethod/initialize.hpp b/include/boost/openmethod/initialize.hpp index 6fc86797..50d4ce56 100644 --- a/include/boost/openmethod/initialize.hpp +++ b/include/boost/openmethod/initialize.hpp @@ -124,6 +124,43 @@ struct initialize_policies> { } }; +// Saves the policies' states on construction, and puts them back on +// destruction unless commit() was called - so a policy's `initialize` that +// throws, after itself or another policy has already written to shared +// state, leaves the registry as it was. The registry's mutable state is one +// variable, registry_state::st; its `policies` tuple is copied +// whole, states of policies without an `initialize` included, since +// restoring an untouched state is harmless, and simpler than picking. The +// other members need no saving: initialize() only reads the class and +// method lists, and write_global_data() replaces dispatch_data at commit +// time only, after which nothing can throw - on rollback it still holds the +// previous tables, which the classes' static_vptrs point into. (That is +// also why a copy could not stand in for it: it would be another buffer.) +template +class registry_state_transaction { + public: + registry_state_transaction() : saved(Registry::state().policies) { + } + + ~registry_state_transaction() { + if (!committed) { + Registry::state().policies = std::move(saved); + } + } + + registry_state_transaction(const registry_state_transaction&) = delete; + auto operator=(const registry_state_transaction&) + -> registry_state_transaction& = delete; + + void commit() { + committed = true; + } + + private: + typename registry_state_type::policies_type saved; + bool committed = false; +}; + inline void merge_into(boost::dynamic_bitset<>& a, boost::dynamic_bitset<>& b) { if (b.size() < a.size()) { b.resize(a.size()); @@ -181,6 +218,11 @@ struct generic_compiler { std::size_t mark = 0; // temporary mark to detect cycles bool transitive_bases_done = false; std::vector vtbl; + // The v-table pointer initialize() is about to install for the class. + // Staged here by write_global_data(), where the policies read it (see + // class_view); written to the class_infos' static_vptr only once + // everything that can fail has succeeded. + vptr_type vptr = nullptr; auto is_base_of(class_* other) const -> bool { return transitive_derived.find(other) != transitive_derived.end(); @@ -268,13 +310,56 @@ struct generic_compiler { std::deque classes; + // What a policy's `initialize` sees for each class (the InitializeClass + // blueprint): the type ids of one class_info, and the v-table pointer + // that initialize() is about to install for the class. `vptr()` is the + // value staged in the class_, not what the class_info's static_vptr + // holds - that is still the previous v-table, and stays so until every + // policy has succeeded. `static_vptr()` is the address that will receive + // it, for the policies that store pointers to v-table pointers + // (indirect_vptr): the one thing here that is stable across + // re-initializations. + struct class_view { + const detail::class_info* ci; + const class_* cls; + + auto type_id_begin() const { + return ci->type_id_begin(); + } + + auto type_id_end() const { + return ci->type_id_end(); + } + + auto vptr() const -> vptr_type { + return cls->vptr; + } + + auto static_vptr() const -> const vptr_type* { + return ci->static_vptr; + } + }; + class const_class_iterator { public: + // The view is made on the fly, so `operator->` has nothing to point + // at; it returns one of these, which points at the view it carries. + // The proxy lives until the end of the full expression, which is as + // long as a policy needs it: `vptr()` and `static_vptr()` return + // values. + struct arrow_proxy { + class_view view; + + auto operator->() const -> const class_view* { + return &view; + } + }; + using iterator_category = std::forward_iterator_tag; - using value_type = const detail::class_info*; + using value_type = class_view; using difference_type = std::ptrdiff_t; - using pointer = const detail::class_info**; - using reference = const detail::class_info*&; + using pointer = arrow_proxy; + using reference = class_view; const_class_iterator() = default; @@ -287,11 +372,12 @@ struct generic_compiler { advance_to_valid(); } } - auto operator->() const -> const detail::class_info* { - return *ci_iter_; + auto operator*() const -> class_view { + return {*ci_iter_, &*class_iter_}; } - auto operator*() const -> const detail::class_info* { - return *ci_iter_; + + auto operator->() const -> arrow_proxy { + return {**this}; } auto operator++() -> const_class_iterator& { @@ -693,11 +779,13 @@ auto registry::compiler::compile() { template template void registry::compiler::initialize() { - // Clear the flag up front: a re-initialize (the documented dlopen/dlclose - // flow) that throws part-way through must not leave the flag `true` from - // the previous successful initialize, or require_initialized() would - // wrongly pass and dispatch would run against half-written tables. Only a - // fully successful run sets it true again. + // Clear the flag up front, and set it only once everything has succeeded. + // A run that throws leaves the previous dispatch state in place, complete + // and consistent (see write_global_data()) - but not marked initialized: + // those tables do not reflect the registrations that prompted the call, + // and after a dlclose (the documented re-initialize flow) they may point + // into unloaded code, so require_initialized() must keep refusing to + // dispatch until an initialize() succeeds. registry::static_::st.initialized = false; compile(); install_global_tables(); @@ -1671,6 +1759,18 @@ void registry::compiler::write_global_data() { using namespace policies; using namespace detail; + // Three steps: stage, initialize the policies, commit. Everything that + // can fail happens in the first two, and neither writes anything shared + // except the policies' own states, which the transaction restores on + // failure: the dispatch data is built in a local vector, and each class' + // v-table pointer is staged in its class_, where the policies read it. + // Only then are the shared locations patched - the method_infos' slots + // and strides, the overriders' `next`, the class_infos' static_vptr - and + // the dispatch data swapped in; none of that can throw. If a policy + // throws, the registry still holds the previous dispatch state, complete + // and consistent, rather than pointers into a vector that unwinding has + // just freed. + auto dispatch_data_size = std::accumulate( methods.begin(), methods.end(), std::size_t(0), [](std::size_t sum, const method& m) { @@ -1689,20 +1789,13 @@ void registry::compiler::write_global_data() { ++tr << "Initializing multi-method dispatch tables at " << gv_iter << "\n"; for (auto& m : methods) { - auto first_info = m.infos[0]; - - if (first_info->arity() == 1) { - // Uni-methods just need an index in the method table. - first_info->slots_strides_ptr[0] = m.slots[0]; - } else { - auto strides_iter = std::copy( - m.slots.begin(), m.slots.end(), first_info->slots_strides_ptr); - std::copy(m.strides.begin(), m.strides.end(), strides_iter); - + // Uni-methods just need an index in the method table, written at + // commit time along with the multi-methods' slots and strides. + if (m.infos[0]->arity() > 1) { if constexpr (has_trace) { ++tr << rflush(4, dispatch_data_size) << " " << " method #" << m.dispatch_table[0]->method_index << " " - << type_name(first_info->method_type_id) << "\n"; + << type_name(m.infos[0]->method_type_id) << "\n"; indent _(tr); for (auto& entry : m.dispatch_table) { @@ -1719,23 +1812,7 @@ void registry::compiler::write_global_data() { } } - // Propagate slots_strides values from the local method_info to all - // other module copies of the same method. Each module's `fn` has its - // own slots_strides[] array; dispatch reads it directly, so every - // copy must hold the same values. - for (auto& m : methods) { - auto count = 2 * m.infos[0]->arity() - 1; - for (auto copy : m.infos) { - if (copy == m.infos[0]) { - continue; - } - std::copy( - m.infos[0]->slots_strides_ptr, - m.infos[0]->slots_strides_ptr + count, copy->slots_strides_ptr); - } - } - - ++tr << "Setting 'next' pointers\n"; + ++tr << "'next' pointers\n"; for (auto& m : methods) { auto first_info = m.infos[0]; @@ -1750,8 +1827,6 @@ void registry::compiler::write_global_data() { tr << "#" << overrider.next->spec_index << " " << spec_name(m, overrider.next); - *overrider.info->next = - reinterpret_cast(overrider.next->pf); } else { tr << "none"; } @@ -1763,9 +1838,7 @@ void registry::compiler::write_global_data() { ++tr << "Initializing v-tables at " << gv_iter << "\n"; for (auto& cls : classes) { - for (auto& ci : cls.ci) { - *ci->static_vptr = gv_iter - cls.first_slot; - } + cls.vptr = gv_iter - cls.first_slot; ++tr << rflush(4, gv_iter - gv_first) << " " << gv_iter << " vtbl for " << cls << " slots " << cls.first_slot << "-" @@ -1805,7 +1878,53 @@ void registry::compiler::write_global_data() { ++tr << rflush(4, dispatch_data_size) << " " << gv_iter << " end\n"; + detail::registry_state_transaction transaction; detail::initialize_policies::fn(*this, options); + transaction.commit(); + + // Commit. Nothing from here on can throw. + + ++tr << "Installing\n"; + + for (auto& m : methods) { + auto first_info = m.infos[0]; + + if (first_info->arity() == 1) { + first_info->slots_strides_ptr[0] = m.slots[0]; + } else { + auto strides_iter = std::copy( + m.slots.begin(), m.slots.end(), first_info->slots_strides_ptr); + std::copy(m.strides.begin(), m.strides.end(), strides_iter); + } + + // Propagate slots_strides values from the local method_info to all + // other module copies of the same method. Each module's `fn` has its + // own slots_strides[] array; dispatch reads it directly, so every + // copy must hold the same values. + auto count = 2 * first_info->arity() - 1; + + for (auto copy : m.infos) { + if (copy != first_info) { + std::copy( + first_info->slots_strides_ptr, + first_info->slots_strides_ptr + count, + copy->slots_strides_ptr); + } + } + + for (auto& overrider : m.overriders) { + if (overrider.next) { + *overrider.info->next = + reinterpret_cast(overrider.next->pf); + } + } + } + + for (auto& cls : classes) { + for (auto& ci : cls.ci) { + *ci->static_vptr = cls.vptr; + } + } new_dispatch_data.swap(static_::st.dispatch_data); } @@ -2096,6 +2215,16 @@ void registry::compiler::print_slots() { //! registered. //! @li The registry's policies may report additional errors. //! +//! @par Exception safety +//! +//! If `initialize` throws - because a policy's `initialize` does, or an +//! allocation fails - the registry keeps the dispatch state it had before the +//! call: no static v-table pointer, `next` pointer, dispatch table or policy +//! state is modified. The registry is nonetheless marked as not initialized, +//! since that state does not reflect the current registrations; `initialize` +//! must be called again, successfully, before any method is called. Policy +//! states are restored from a copy, so a policy's `state` must be copyable. +//! //! @par Example //! //! Initialize the default registry with tracing enabled, and exit with an error diff --git a/include/boost/openmethod/policies/vptr_map.hpp b/include/boost/openmethod/policies/vptr_map.hpp index 4b176057..4c4834d5 100644 --- a/include/boost/openmethod/policies/vptr_map.hpp +++ b/include/boost/openmethod/policies/vptr_map.hpp @@ -74,7 +74,7 @@ class vptr_map : public vptr { type_iter != iter->type_id_end(); ++type_iter) { if constexpr (Registry::has_indirect_vptr) { - new_vptrs.emplace(*type_iter, &iter->vptr()); + new_vptrs.emplace(*type_iter, iter->static_vptr()); } else { new_vptrs.emplace(*type_iter, iter->vptr()); } diff --git a/include/boost/openmethod/policies/vptr_vector.hpp b/include/boost/openmethod/policies/vptr_vector.hpp index eb9755c1..c1716ef9 100644 --- a/include/boost/openmethod/policies/vptr_vector.hpp +++ b/include/boost/openmethod/policies/vptr_vector.hpp @@ -128,7 +128,7 @@ struct vptr_vector : vptr { } if constexpr (Registry::has_indirect_vptr) { - st().vptrs[index] = &iter->vptr(); + st().vptrs[index] = iter->static_vptr(); } else { st().vptrs[index] = iter->vptr(); } diff --git a/include/boost/openmethod/preamble.hpp b/include/boost/openmethod/preamble.hpp index 53408119..eb0ed323 100644 --- a/include/boost/openmethod/preamble.hpp +++ b/include/boost/openmethod/preamble.hpp @@ -520,10 +520,24 @@ struct InitializeClass { //! class. auto type_id_end() const -> detail::unspecified; - //! Reference to the v-table pointer for the class. + //! The v-table pointer for the class. //! - //! @return A reference to the v-table pointer for the class. - auto vptr() const -> const vptr_type&; + //! The value that @ref initialize installs for the class when it succeeds. + //! Store it to associate the class' type ids with its v-table. + //! + //! @return The v-table pointer for the class. + auto vptr() const -> vptr_type; + + //! Address of the class' static v-table pointer. + //! + //! @ref initialize writes @ref vptr to this location when it succeeds, and + //! every later call updates it in place. A @ref VptrFn that stores pointers + //! to v-table pointers, for use with the @ref indirect_vptr policy, stores + //! this address; `*static_vptr()` still holds the previous v-table pointer + //! while `initialize` runs. + //! + //! @return A pointer to the static v-table pointer for the class. + auto static_vptr() const -> const vptr_type*; }; //! Context for initializing a policy (exposition only). @@ -707,6 +721,10 @@ struct VptrFn { //! Called by @ref registry::initialize to let the policy store the v-table //! pointer associated to each `type_id`. //! + //! If this function, or another policy's `initialize`, throws, the + //! policy's `state` is restored to its previous value; see @ref + //! initialize. + //! //! @tparam Context A class that conforms to the @ref InitializeContext //! blueprint. //! @tparam Options... Zero or more option types, deduced from the @@ -799,6 +817,10 @@ template struct TypeHashFn { //! Initialize the hash table. //! + //! If this function, or another policy's `initialize`, throws, the + //! policy's `state` is restored to its previous value; see @ref + //! initialize. + //! //! @tparam Context A class that conforms to the @ref InitializeContext //! blueprint. //! @tparam Options... Zero or more option types, deduced from the diff --git a/test/test_initialize_transaction.cpp b/test/test_initialize_transaction.cpp new file mode 100644 index 00000000..11c1c7d6 --- /dev/null +++ b/test/test_initialize_transaction.cpp @@ -0,0 +1,221 @@ +// Copyright (c) 2017-2026 Jean-Louis Leroy +// Distributed under the Boost Software License, Version 1.0. +// See accompanying file LICENSE_1_0.txt +// or copy at http://www.boost.org/LICENSE_1_0.txt) + +// initialize() is transactional: if a policy's initialize throws, the +// registry keeps the dispatch state it had before the call - the static +// v-table pointers, the `next` pointers, the dispatch data and every policy's +// state - instead of pointers into a vector that unwinding has freed. It is +// marked as not initialized, though, until a call succeeds. + +#include +#include +#include +#include + +#define BOOST_TEST_MODULE initialize_transaction +#include + +#include "test_util.hpp" + +#include +#include +#include + +using boost::mp11::mp_list; +using namespace boost::openmethod; + +// A stateful policy whose `initialize` throws on demand. It writes to its +// state before throwing, so a rollback is observable there too; and it +// comes last in the policy list (see the static_assert below), so by the time +// it throws, the type_hash and vptr policies have written their new states. +struct explosive_policy { + using category = explosive_policy; + + template + struct fn { + struct state { + int generation = 0; + }; + + inline static bool armed = false; + inline static int generations = 0; + + template + static void initialize(const Context&, const std::tuple&) { + Registry::template state().generation = + ++generations; + + if (armed) { + throw std::runtime_error("boom"); + } + } + }; +}; + +template +struct vector_registry : + test_registry_::template with< + policies::runtime_checks, policies::throw_error_handler, + explosive_policy> {}; + +template +struct map_registry : + test_registry_::template with< + policies::runtime_checks, policies::throw_error_handler, + policies::vptr_map<>, policies::indirect_vptr, explosive_policy> {}; + +template +using registries = mp_list, map_registry>; + +template +constexpr bool explosive_comes_last = + boost::mp11::mp_find< + typename Registry::policy_list, explosive_policy>::value > + boost::mp11::mp_find< + typename Registry::policy_list, + detail::find_first_derived_of< + policies::vptr, typename Registry::policy_list>>::value; + +static_assert(explosive_comes_last>); +static_assert(explosive_comes_last>); + +struct Animal { + virtual ~Animal() = default; +}; + +struct Dog : Animal {}; +struct Cat : Animal {}; + +struct BOOST_OPENMETHOD_ID(poke); + +template +using poke = method< + BOOST_OPENMETHOD_ID(poke), auto(virtual_)->std::string, Registry>; + +template +auto poke_animal(Animal&) -> std::string { + return "silence"; +} + +template +auto poke_dog(Dog& dog) -> std::string { + return poke::template next>(dog) + " bark"; +} + +template +struct snapshot { + using vptr_state = + typename Registry::template policy::state; + using type_hash = typename Registry::template policy; + + snapshot() : + dispatch_data(Registry::state().dispatch_data.data()), + dog_vptr(Registry::template static_vptr), + cat_vptr(Registry::template static_vptr), + next(poke::template next>), + hash_range(type_hash::hash_range()), + policies(Registry::state().policies) { + } + + auto vptrs() -> decltype(auto) { + return (detail::get(policies).vptrs); + } + + const detail::word* dispatch_data; + vptr_type dog_vptr; + vptr_type cat_vptr; + decltype(poke::template next>) next; + std::pair hash_range; + decltype(Registry::state().policies) policies; +}; + +BOOST_AUTO_TEST_CASE_TEMPLATE( + failed_reinitialize_keeps_previous_state, Registry, + registries<__COUNTER__>) { + using explosive = typename explosive_policy::template fn< + typename Registry::registry_type>; + using vptr_state = typename snapshot::vptr_state; + + BOOST_OPENMETHOD_REGISTER(use_classes); + BOOST_OPENMETHOD_REGISTER( + typename poke::template override>); + BOOST_OPENMETHOD_REGISTER( + typename poke::template override>); + + Dog dog; + Cat cat; + auto& st = Registry::state(); + + initialize(); + BOOST_TEST(st.initialized); + BOOST_TEST(poke::fn(dog) == "silence bark"); + BOOST_TEST(poke::fn(cat) == "silence"); + BOOST_TEST(Registry::template state().generation == 1); + + snapshot before; + + explosive::armed = true; + BOOST_CHECK_THROW(initialize(), std::runtime_error); + explosive::armed = false; + + // Not initialized, but everything the previous call installed is still + // there, and consistent... + BOOST_TEST(!st.initialized); + BOOST_TEST(st.dispatch_data.data() == before.dispatch_data); + BOOST_TEST(Registry::template static_vptr == before.dog_vptr); + BOOST_TEST(Registry::template static_vptr == before.cat_vptr); + BOOST_TEST( + poke::template next> == before.next); + BOOST_TEST( + (snapshot::type_hash::hash_range() == before.hash_range)); + BOOST_TEST((detail::get(st.policies).vptrs == before.vptrs())); + // ...including the state of the policy that threw, after writing to it. + BOOST_TEST(Registry::template state().generation == 1); + + // ...but dispatch is refused until an initialize() succeeds. + BOOST_CHECK_THROW(poke::fn(dog), not_initialized); + + initialize(); + BOOST_TEST(st.initialized); + BOOST_TEST(poke::fn(dog) == "silence bark"); + BOOST_TEST(poke::fn(cat) == "silence"); + BOOST_TEST(Registry::template state().generation == 3); +} + +BOOST_AUTO_TEST_CASE_TEMPLATE( + failed_first_initialize_leaves_registry_clean, Registry, + registries<__COUNTER__>) { + using explosive = typename explosive_policy::template fn< + typename Registry::registry_type>; + using vptr_state = typename snapshot::vptr_state; + + BOOST_OPENMETHOD_REGISTER(use_classes); + BOOST_OPENMETHOD_REGISTER( + typename poke::template override>); + BOOST_OPENMETHOD_REGISTER( + typename poke::template override>); + + Dog dog; + auto& st = Registry::state(); + + explosive::armed = true; + BOOST_CHECK_THROW(initialize(), std::runtime_error); + explosive::armed = false; + + BOOST_TEST(!st.initialized); + BOOST_TEST(st.dispatch_data.empty()); + BOOST_TEST(Registry::template static_vptr == nullptr); + BOOST_TEST(detail::get(st.policies).vptrs.empty()); + BOOST_TEST(Registry::template state().generation == 0); + BOOST_CHECK_THROW(poke::fn(dog), not_initialized); + + // finalize() has nothing to undo, and must not mind. + finalize(); + BOOST_TEST(!st.initialized); + + initialize(); + BOOST_TEST(st.initialized); + BOOST_TEST(poke::fn(dog) == "silence bark"); +} From 6907b5cf7b5c72602665ebb920d2828eb369f930 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leroy Date: Tue, 1 Sep 2026 19:23:47 -0400 Subject: [PATCH 2/2] test: stop clang-cl from having to stream a function pointer BOOST_TEST(a == b) decomposes the comparison so that it can print both operands when it fails. For the `next` pointers that means streaming a function pointer, which is a Microsoft extension: clang-cl rejects it under /WX (-Wmicrosoft-cast), so test_initialize_transaction did not compile in any of the clang-win CI variants. Wrap the comparison in an extra pair of parentheses, as the neighbouring checks already do, so Boost.Test sees a bool. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VGX6XBwxEr1rqxGvAyvUgB --- test/test_initialize_transaction.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/test_initialize_transaction.cpp b/test/test_initialize_transaction.cpp index 11c1c7d6..551618df 100644 --- a/test/test_initialize_transaction.cpp +++ b/test/test_initialize_transaction.cpp @@ -166,8 +166,11 @@ BOOST_AUTO_TEST_CASE_TEMPLATE( BOOST_TEST(st.dispatch_data.data() == before.dispatch_data); BOOST_TEST(Registry::template static_vptr == before.dog_vptr); BOOST_TEST(Registry::template static_vptr == before.cat_vptr); + // Parenthesized: on failure Boost.Test would print the operands, and + // streaming a function pointer is a Microsoft extension that clang-cl + // rejects under /WX (-Wmicrosoft-cast). BOOST_TEST( - poke::template next> == before.next); + (poke::template next> == before.next)); BOOST_TEST( (snapshot::type_hash::hash_range() == before.hash_range)); BOOST_TEST((detail::get(st.policies).vptrs == before.vptrs()));