diff --git a/include/ariec61850/mms/dynamic_data_set.hpp b/include/ariec61850/mms/dynamic_data_set.hpp index 8b0f1abb3..1bb0947b4 100644 --- a/include/ariec61850/mms/dynamic_data_set.hpp +++ b/include/ariec61850/mms/dynamic_data_set.hpp @@ -145,10 +145,25 @@ class MmsDynamicDataSetRuntime final { [[nodiscard]] MmsDataSetDirectoryResponse verify( const MmsObjectName& data_set_name, std::stop_token stop_token); + void synchronize_ownership_scope() const; + void capture_ownership_scope() const; + void clear_ownership_scope() const noexcept; + void require_request_within_negotiated_limit( + std::span mms_pdu, + const char* operation) const; MmsAssociationRuntime& association_; MmsDynamicDataSetOptions options_{}; - std::set> owned_data_sets_; + mutable std::set> owned_data_sets_; + mutable bool ownership_scope_valid_{}; + mutable MmsEndpoint ownership_endpoint_{}; + mutable std::size_t ownership_event_count_{}; + mutable MmsAssociationEventKind ownership_marker_kind_{ + MmsAssociationEventKind::state_changed}; + mutable MmsAssociationRuntimeState ownership_marker_state_{ + MmsAssociationRuntimeState::disconnected}; + mutable std::optional ownership_marker_invoke_id_; + mutable std::string ownership_marker_message_; }; } // namespace ar::iec61850::mms diff --git a/src/mms/dynamic_data_set.cpp b/src/mms/dynamic_data_set.cpp index f3a20d9fd..f0de99808 100644 --- a/src/mms/dynamic_data_set.cpp +++ b/src/mms/dynamic_data_set.cpp @@ -382,6 +382,92 @@ std::string MmsDynamicDataSetRuntime::normalize_reference( return MmsDataSetDirectoryCodec::to_iec_reference(name); } +void MmsDynamicDataSetRuntime::clear_ownership_scope() const noexcept { + owned_data_sets_.clear(); + ownership_scope_valid_ = false; + ownership_endpoint_ = {}; + ownership_event_count_ = 0U; + ownership_marker_kind_ = MmsAssociationEventKind::state_changed; + ownership_marker_state_ = MmsAssociationRuntimeState::disconnected; + ownership_marker_invoke_id_.reset(); + ownership_marker_message_.clear(); +} + +void MmsDynamicDataSetRuntime::capture_ownership_scope() const { + const auto& events = association_.events(); + if (!association_.associated() || events.empty()) { + clear_ownership_scope(); + return; + } + ownership_scope_valid_ = true; + ownership_endpoint_ = association_.endpoint(); + ownership_event_count_ = events.size(); + const auto& marker = events.back(); + ownership_marker_kind_ = marker.kind; + ownership_marker_state_ = marker.state; + ownership_marker_invoke_id_ = marker.invoke_id; + ownership_marker_message_ = marker.message; +} + +void MmsDynamicDataSetRuntime::synchronize_ownership_scope() const { + if (owned_data_sets_.empty()) { + if (!association_.associated()) { + clear_ownership_scope(); + } + return; + } + if (!ownership_scope_valid_ || !association_.associated() || + association_.endpoint() != ownership_endpoint_) { + clear_ownership_scope(); + return; + } + + const auto& events = association_.events(); + if (ownership_event_count_ == 0U || events.size() < ownership_event_count_) { + clear_ownership_scope(); + return; + } + const auto& marker = events[ownership_event_count_ - 1U]; + if (marker.kind != ownership_marker_kind_ || + marker.state != ownership_marker_state_ || + marker.invoke_id != ownership_marker_invoke_id_ || + marker.message != ownership_marker_message_) { + // The bounded event deque shifted, so the original association scope can + // no longer be proven. Forget ownership rather than risk deleting a + // foreign DataSet. + clear_ownership_scope(); + return; + } + + for (std::size_t index = ownership_event_count_; index < events.size(); ++index) { + switch (events[index].kind) { + case MmsAssociationEventKind::association_accepted: + case MmsAssociationEventKind::disconnected: + case MmsAssociationEventKind::cancelled: + case MmsAssociationEventKind::timed_out: + case MmsAssociationEventKind::faulted: + clear_ownership_scope(); + return; + default: + break; + } + } +} + +void MmsDynamicDataSetRuntime::require_request_within_negotiated_limit( + const std::span mms_pdu, + const char* operation) const { + const auto negotiated_limit = + static_cast(association_.negotiated().maximum_mms_pdu_size); + if (negotiated_limit == 0U || mms_pdu.size() > negotiated_limit) { + throw MmsDynamicDataSetError( + std::string{"MMS "} + operation + " request is " + + std::to_string(mms_pdu.size()) + + " bytes and exceeds the negotiated maximum MMS PDU size of " + + std::to_string(negotiated_limit) + " bytes."); + } +} + MmsDataSetDirectoryResponse MmsDynamicDataSetRuntime::verify( const MmsObjectName& data_set_name, const std::stop_token stop_token) { @@ -401,9 +487,11 @@ MmsDynamicDataSetCreateResult MmsDynamicDataSetRuntime::create( const std::span members, const std::stop_token stop_token) { if (!association_.associated()) { + clear_ownership_scope(); throw MmsDynamicDataSetError( "Cannot create a dynamic DataSet without an active MMS association."); } + synchronize_ownership_scope(); if (members.empty() || members.size() > options_.maximum_members) { throw MmsDynamicDataSetError( "Dynamic DataSet member count exceeds the configured operational limit."); @@ -413,7 +501,7 @@ MmsDynamicDataSetCreateResult MmsDynamicDataSetRuntime::create( const auto normalized = normalize_reference(data_set_reference); if (owned_data_sets_.contains(normalized)) { throw MmsDynamicDataSetError( - "This runtime already owns the requested dynamic DataSet reference."); + "This runtime already owns the requested dynamic DataSet reference in the current association scope."); } const auto data_set_name = MmsDataSetDirectoryCodec::parse_data_set_reference(normalized); @@ -422,8 +510,10 @@ MmsDynamicDataSetCreateResult MmsDynamicDataSetRuntime::create( request.invoke_id = invoke_id; request.data_set_name = data_set_name; request.members.assign(members.begin(), members.end()); - const auto encoded = MmsNamedVariableListCodec::encode_define_request_p_data( - request, association_.negotiated().presentation_context_id); + const auto pdu = MmsNamedVariableListCodec::encode_define_request_pdu(request); + require_request_within_negotiated_limit(pdu, "DefineNamedVariableList"); + const auto encoded = MmsPduCodec::wrap_p_data( + pdu, association_.negotiated().presentation_context_id); const auto exchange = association_.exchange_confirmed( encoded, invoke_id, stop_token); require_confirmed_response(exchange, "DefineNamedVariableList"); @@ -459,7 +549,16 @@ MmsDynamicDataSetCreateResult MmsDynamicDataSetRuntime::create( } } + const bool first_owned_data_set = owned_data_sets_.empty(); owned_data_sets_.insert(normalized); + if (first_owned_data_set) { + capture_ownership_scope(); + if (!ownership_scope_valid_) { + owned_data_sets_.erase(normalized); + throw MmsDynamicDataSetError( + "Dynamic DataSet was created, but the current association ownership scope could not be retained safely."); + } + } return result; } @@ -470,8 +569,10 @@ MmsDeleteNamedVariableListResponse MmsDynamicDataSetRuntime::remove_impl( MmsDataSetDirectoryCodec::parse_data_set_reference(normalized_reference); const auto invoke_id = association_.next_invoke_id(); const MmsDeleteNamedVariableListRequest request{invoke_id, data_set_name}; - const auto encoded = MmsNamedVariableListCodec::encode_delete_request_p_data( - request, association_.negotiated().presentation_context_id); + const auto pdu = MmsNamedVariableListCodec::encode_delete_request_pdu(request); + require_request_within_negotiated_limit(pdu, "DeleteNamedVariableList"); + const auto encoded = MmsPduCodec::wrap_p_data( + pdu, association_.negotiated().presentation_context_id); const auto exchange = association_.exchange_confirmed( encoded, invoke_id, stop_token); require_confirmed_response(exchange, "DeleteNamedVariableList"); @@ -484,19 +585,24 @@ MmsDeleteNamedVariableListResponse MmsDynamicDataSetRuntime::remove( const MmsDynamicDataSetDeletePolicy policy, const std::stop_token stop_token) { if (!association_.associated()) { + clear_ownership_scope(); throw MmsDynamicDataSetError( "Cannot delete a dynamic DataSet without an active MMS association."); } + synchronize_ownership_scope(); const auto normalized = normalize_reference(data_set_reference); const auto owned = owned_data_sets_.contains(normalized); if (!owned && policy != MmsDynamicDataSetDeletePolicy::explicit_override) { throw MmsDynamicDataSetError( - "Refusing to delete a DataSet not created by this runtime; use explicit_override intentionally."); + "Refusing to delete a DataSet not created in the current association scope; use explicit_override intentionally."); } const auto response = remove_impl(normalized, stop_token); if (response.deleted() || response.number_matched.value_or(1U) == 0U) { owned_data_sets_.erase(normalized); + if (owned_data_sets_.empty()) { + clear_ownership_scope(); + } } return response; } @@ -504,6 +610,7 @@ MmsDeleteNamedVariableListResponse MmsDynamicDataSetRuntime::remove( bool MmsDynamicDataSetRuntime::owns( const std::string& data_set_reference) const { try { + synchronize_ownership_scope(); return owned_data_sets_.contains(normalize_reference(data_set_reference)); } catch (...) { return false; @@ -511,6 +618,7 @@ bool MmsDynamicDataSetRuntime::owns( } std::vector MmsDynamicDataSetRuntime::owned_data_sets() const { + synchronize_ownership_scope(); return {owned_data_sets_.begin(), owned_data_sets_.end()}; } diff --git a/tests/test_dynamic_data_set.cpp b/tests/test_dynamic_data_set.cpp index c7708690b..b243524f6 100644 --- a/tests/test_dynamic_data_set.cpp +++ b/tests/test_dynamic_data_set.cpp @@ -1,15 +1,20 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/acse/association.hpp" #include "ariec61850/mms/dynamic_data_set.hpp" #include "ariec61850/mms/pdu.hpp" +#include "ariec61850/osi/cotp.hpp" +#include "ariec61850/osi/tpkt.hpp" #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -24,11 +29,14 @@ using ar::iec61850::mms::MmsDefineNamedVariableListResponse; using ar::iec61850::mms::MmsDeleteNamedVariableListRequest; using ar::iec61850::mms::MmsDeleteNamedVariableListResponse; using ar::iec61850::mms::MmsDynamicDataSetError; +using ar::iec61850::mms::MmsDynamicDataSetOptions; using ar::iec61850::mms::MmsDynamicDataSetRuntime; using ar::iec61850::mms::MmsEndpoint; using ar::iec61850::mms::MmsNamedVariableListCodec; using ar::iec61850::mms::MmsObjectName; using ar::iec61850::mms::MmsPduCodec; +namespace acse = ar::iec61850::acse; +namespace osi = ar::iec61850::osi; #define CHECK(condition) \ do { \ @@ -94,6 +102,88 @@ class NullTransport final : public MmsByteTransport { [[nodiscard]] bool connected() const noexcept override { return false; } }; +class ScriptedTransport final : public MmsByteTransport { +public: + void connect( + const MmsEndpoint& endpoint, + Deadline, + std::stop_token stop_token) override { + if (stop_token.stop_requested()) { + throw std::runtime_error("scripted connect cancelled"); + } + endpoint_ = endpoint; + connected_ = true; + } + + void send( + const std::span bytes, + Deadline, + std::stop_token stop_token) override { + if (stop_token.stop_requested()) { + throw std::runtime_error("scripted send cancelled"); + } + if (!connected_) { + throw std::runtime_error("scripted transport is disconnected"); + } + sent_.emplace_back(bytes.begin(), bytes.end()); + } + + [[nodiscard]] std::vector receive( + Deadline, + std::stop_token stop_token) override { + if (stop_token.stop_requested()) { + throw std::runtime_error("scripted receive cancelled"); + } + if (!connected_) { + throw std::runtime_error("scripted transport is disconnected"); + } + if (receive_queue_.empty()) { + throw ar::iec61850::mms::MmsTransportTimeoutError( + "scripted receive timeout"); + } + auto value = std::move(receive_queue_.front()); + receive_queue_.pop_front(); + return value; + } + + void close() noexcept override { connected_ = false; } + [[nodiscard]] bool connected() const noexcept override { return connected_; } + + void push_receive(std::vector bytes) { + receive_queue_.push_back(std::move(bytes)); + } + + [[nodiscard]] const std::vector>& sent() const noexcept { + return sent_; + } + +private: + MmsEndpoint endpoint_; + bool connected_{}; + std::deque> receive_queue_; + std::vector> sent_; +}; + +[[nodiscard]] std::vector wrap_application( + const std::span application) { + const auto cotp = osi::CotpFrameCodec::encode_data(application); + return osi::TpktFrameCodec::encode(cotp); +} + +void queue_handshake(ScriptedTransport& transport) { + const auto request_bytes = + acse::AcseAssociationCodec::build_default_association_request(); + const auto confirm = osi::CotpFrameCodec::encode_connection_confirm( + 0x0001U, 0x2345U, 0x0AU); + transport.push_receive(osi::TpktFrameCodec::encode(confirm)); + + const auto request = acse::AcseAssociationCodec::decode_association_request( + request_bytes); + const auto response = acse::AcseAssociationCodec::build_accept_response( + request, acse::AcseAssociationCodec::default_accept_aare()); + transport.push_receive(wrap_application(response.payload)); +} + MmsDefineNamedVariableListRequest oracle_define_request() { MmsDefineNamedVariableListRequest request; request.invoke_id = 8U; @@ -198,6 +288,68 @@ void runtime_requires_live_association_before_mutation() { }); } +void ownership_is_invalidated_across_same_endpoint_reconnect() { + ScriptedTransport transport; + MmsAssociationRuntime association{transport}; + const MmsEndpoint endpoint{"127.0.0.1", 102U}; + queue_handshake(transport); + association.connect(endpoint); + + MmsDynamicDataSetOptions options; + options.verify_after_create = false; + MmsDynamicDataSetRuntime runtime{association, options}; + const auto request = oracle_define_request(); + + transport.push_receive(wrap_application( + MmsNamedVariableListCodec::encode_define_response_p_data({1U}))); + const auto created = runtime.create( + "LD0/LLN0.AR_DYN_DS01", request.members); + CHECK(created.data_set_reference == "LD0/LLN0.AR_DYN_DS01"); + CHECK(runtime.owns(created.data_set_reference)); + + association.disconnect(); + queue_handshake(transport); + association.connect(endpoint); + + CHECK(!runtime.owns(created.data_set_reference)); + const auto sent_before = transport.sent().size(); + check_throws([&] { + static_cast(runtime.remove(created.data_set_reference)); + }); + CHECK(transport.sent().size() == sent_before); + association.disconnect(); +} + +void oversized_define_is_rejected_before_network_send() { + ScriptedTransport transport; + MmsAssociationRuntime association{transport}; + const MmsEndpoint endpoint{"127.0.0.1", 102U}; + queue_handshake(transport); + association.connect(endpoint); + + MmsDynamicDataSetOptions options; + options.maximum_members = 512U; + options.verify_after_create = false; + MmsDynamicDataSetRuntime runtime{association, options}; + + std::vector members; + members.reserve(300U); + for (std::size_t index = 0U; index < 300U; ++index) { + std::string item = "GGIO1$ST$"; + item.append(220U, 'A'); + item += std::to_string(index); + members.push_back(MmsObjectName::domain_specific("LD0", std::move(item))); + } + + const auto sent_before = transport.sent().size(); + check_throws([&] { + static_cast(runtime.create( + "LD0/LLN0.AR_OVERSIZED", members)); + }); + CHECK(transport.sent().size() == sent_before); + association.disconnect(); +} + } // namespace int main() { @@ -208,6 +360,8 @@ int main() { delete_response_reads_and_emits_csharp_oracle_counts(); invalid_or_unsafe_requests_fail_closed(); runtime_requires_live_association_before_mutation(); + ownership_is_invalidated_across_same_endpoint_reconnect(); + oversized_define_is_rejected_before_network_send(); std::cout << "Dynamic DataSet tests passed.\n"; return 0; } catch (const std::exception& exception) { diff --git a/tools/dynamic_rcb_trial.cpp b/tools/dynamic_rcb_trial.cpp index a78f1cc59..b4435ef79 100644 --- a/tools/dynamic_rcb_trial.cpp +++ b/tools/dynamic_rcb_trial.cpp @@ -246,26 +246,56 @@ void print_candidates(const mms::MmsRcbSelectionEvidence& selection) { } } +[[nodiscard]] bool subscription_wrote_attribute( + const mms::MmsReportSubscriptionSnapshot& snapshot, + const std::string_view attribute) { + const std::string expected = "RCB attribute written: " + std::string{attribute} + "."; + return std::any_of( + snapshot.events.begin(), snapshot.events.end(), + [&expected](const auto& event) { + return event.kind == mms::MmsReportSubscriptionEventKind::attribute_written && + event.message == expected; + }); +} + void best_effort_cleanup( mms::MmsAssociationRuntime& association, const mms::MmsReportControlCandidate* candidate, + const mms::MmsReportSubscriptionSnapshot* subscription_snapshot, mms::MmsDynamicDataSetRuntime* dynamic_data_set, const std::string& data_set_reference, const bool data_set_created) noexcept { if (!association.associated()) return; - if (candidate != nullptr) { - try { require_write_success(association, *candidate, "RptEna", mms::MmsDataValue::boolean(false)); } - catch (const std::exception& exception) { std::cerr << "CLEANUP warning: RptEna=false failed: " << exception.what() << '\n'; } - if (!candidate->buffered && contains_attribute(*candidate, "Resv")) { - try { require_write_success(association, *candidate, "Resv", mms::MmsDataValue::boolean(false)); } - catch (const std::exception& exception) { std::cerr << "CLEANUP warning: Resv=false failed: " << exception.what() << '\n'; } - } - if (contains_attribute(*candidate, "DatSet")) { - try { require_write_success(association, *candidate, "DatSet", mms::MmsDataValue::visible_string("")); } - catch (const std::exception& exception) { std::cerr << "CLEANUP warning: DatSet unbind failed: " << exception.what() << '\n'; } + + bool safe_to_delete_data_set = true; + const bool data_set_binding_touched = + candidate != nullptr && subscription_snapshot != nullptr && + subscription_wrote_attribute(*subscription_snapshot, "DatSet"); + + // RptEna and Resv cleanup belongs exclusively to + // MmsReportSubscriptionRuntime, which already tracks whether this run + // successfully touched those attributes. Never emit unconditional cleanup + // writes here: a re-probe may have rejected the RCB because another client + // claimed it after discovery. + if (subscription_snapshot != nullptr && subscription_snapshot->cleanup_required) { + safe_to_delete_data_set = false; + std::cerr << "CLEANUP warning: subscription still has owned RCB cleanup pending; " + "retaining any bound Dynamic DataSet rather than unbinding/deleting unsafely.\n"; + } else if (data_set_binding_touched && candidate != nullptr && + contains_attribute(*candidate, "DatSet")) { + try { + require_write_success( + association, *candidate, "DatSet", + mms::MmsDataValue::visible_string("")); + std::cerr << "CLEANUP RCB DatSet unbound because this run wrote the binding.\n"; + } catch (const std::exception& exception) { + safe_to_delete_data_set = false; + std::cerr << "CLEANUP warning: owned DatSet unbind failed: " + << exception.what() << '\n'; } } - if (data_set_created && dynamic_data_set != nullptr) { + + if (data_set_created && dynamic_data_set != nullptr && safe_to_delete_data_set) { try { const auto response = dynamic_data_set->remove(data_set_reference); std::cerr << "CLEANUP Dynamic DataSet delete matched=" << response.number_matched.value_or(0U) @@ -273,6 +303,8 @@ void best_effort_cleanup( } catch (const std::exception& exception) { std::cerr << "CLEANUP warning: Dynamic DataSet delete failed: " << exception.what() << '\n'; } + } else if (data_set_created && !safe_to_delete_data_set) { + std::cerr << "CLEANUP retained Dynamic DataSet because safe RCB cleanup could not be proven.\n"; } } @@ -465,10 +497,15 @@ int main(int argc, char** argv) { std::cout << "SMART_DYNAMIC_RCB_TRIAL_PASS\n"; return 0; } catch (...) { - if (subscription) subscription->stop(); + std::optional cleanup_snapshot; + if (subscription) { + subscription->stop(); + cleanup_snapshot = subscription->snapshot(); + } best_effort_cleanup( - session.association(), candidate, &dynamic_data_set, - data_set_reference, data_set_created); + session.association(), candidate, + cleanup_snapshot ? &*cleanup_snapshot : nullptr, + &dynamic_data_set, data_set_reference, data_set_created); session.disconnect(); throw; } diff --git a/tools/dynamic_rcb_trial/CMakeLists.txt b/tools/dynamic_rcb_trial/CMakeLists.txt index 2ad9e0637..9758ceff4 100644 --- a/tools/dynamic_rcb_trial/CMakeLists.txt +++ b/tools/dynamic_rcb_trial/CMakeLists.txt @@ -56,3 +56,20 @@ else() endif() add_test(NAME dynamic_report_planner_tests COMMAND ariec61850_dynamic_report_planner_tests) + +add_executable(ariec61850_dynamic_cleanup_ownership_tests + ${CMAKE_CURRENT_LIST_DIR}/test_dynamic_cleanup_ownership.cpp) +target_link_libraries(ariec61850_dynamic_cleanup_ownership_tests PRIVATE + ARIEC61850::core) +target_compile_features(ariec61850_dynamic_cleanup_ownership_tests PRIVATE cxx_std_20) +if(MSVC) + target_compile_options(ariec61850_dynamic_cleanup_ownership_tests PRIVATE /W4 /permissive- /WX) +else() + target_compile_options(ariec61850_dynamic_cleanup_ownership_tests PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) +endif() +if(WIN32) + target_link_libraries(ariec61850_dynamic_cleanup_ownership_tests PRIVATE ws2_32) +endif() +add_test(NAME dynamic_cleanup_ownership_tests + COMMAND ariec61850_dynamic_cleanup_ownership_tests) diff --git a/tools/dynamic_rcb_trial/test_dynamic_cleanup_ownership.cpp b/tools/dynamic_rcb_trial/test_dynamic_cleanup_ownership.cpp new file mode 100644 index 000000000..fb62ce787 --- /dev/null +++ b/tools/dynamic_rcb_trial/test_dynamic_cleanup_ownership.cpp @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#define main ariec61850_dynamic_rcb_trial_entry_for_test +#include "../dynamic_rcb_trial.cpp" +#undef main + +#include +#include +#include + +namespace { + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + throw std::runtime_error( \ + std::string{"CHECK failed: "} + #condition + \ + " at " + __FILE__ + ":" + std::to_string(__LINE__)); \ + } \ + } while (false) + +void cleanup_requires_positive_mutation_evidence() { + ar::iec61850::mms::MmsReportSubscriptionSnapshot snapshot; + CHECK(!subscription_wrote_attribute(snapshot, "DatSet")); + CHECK(!subscription_wrote_attribute(snapshot, "RptEna")); + + snapshot.events.push_back({ + ar::iec61850::mms::MmsReportSubscriptionEventKind::probe_completed, + ar::iec61850::mms::MmsReportSubscriptionState::probing, + "RCB live-state probe completed."}); + CHECK(!subscription_wrote_attribute(snapshot, "DatSet")); + + snapshot.events.push_back({ + ar::iec61850::mms::MmsReportSubscriptionEventKind::attribute_written, + ar::iec61850::mms::MmsReportSubscriptionState::configuring, + "RCB attribute written: DatSet."}); + CHECK(subscription_wrote_attribute(snapshot, "DatSet")); + CHECK(!subscription_wrote_attribute(snapshot, "RptEna")); + + snapshot.events.push_back({ + ar::iec61850::mms::MmsReportSubscriptionEventKind::attribute_written, + ar::iec61850::mms::MmsReportSubscriptionState::enabling, + "RCB attribute written: RptEna."}); + CHECK(subscription_wrote_attribute(snapshot, "DatSet")); + CHECK(subscription_wrote_attribute(snapshot, "RptEna")); +} + +} // namespace + +int main() { + try { + cleanup_requires_positive_mutation_evidence(); + std::cout << "Dynamic RCB cleanup ownership tests passed.\n"; + return 0; + } catch (const std::exception& exception) { + std::cerr << "Dynamic RCB cleanup ownership test failure: " + << exception.what() << '\n'; + return 1; + } +}