From 2cca95b2374e563241bc48aea20b52f42179bb0e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 17:56:30 +0700 Subject: [PATCH 1/5] feat(mms): bounded association-owned confirmed batch with verified negotiated window --- .../ariec61850/mms/association_runtime.hpp | 25 ++++ src/mms/association_runtime.cpp | 122 ++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/include/ariec61850/mms/association_runtime.hpp b/include/ariec61850/mms/association_runtime.hpp index 424a0ac5a..309a069ad 100644 --- a/include/ariec61850/mms/association_runtime.hpp +++ b/include/ariec61850/mms/association_runtime.hpp @@ -154,6 +154,13 @@ struct MmsConfirmedExchangeResult final { std::vector presentation_payload; }; +// Caller supplies complete encoded P-DATA. Invoke IDs are validated against +// the encoded requests before any bytes are sent to the association. +struct MmsConfirmedBatchRequest final { + std::vector presentation_payload; + std::uint32_t invoke_id{}; +}; + class MmsAssociationRuntime final { public: explicit MmsAssociationRuntime( @@ -182,6 +189,22 @@ class MmsAssociationRuntime final { // A response timeout after a successful send still leaves this true. bool* request_sent = nullptr); + // Association-owned, synchronous *wire pipeline*, not a thread pool. + // Sends complete requests serially, then receives and dispatches responses + // by exact invoke-ID. Must never overlap poll/receive/exchange calls. + // Only available after a strict, positive negotiated calling window. + // sent_count records completed transmissions even when a later send/recv + // fails. The association is faulted/closed on any in-flight failure. + [[nodiscard]] std::vector exchange_confirmed_batch( + std::span requests, + std::stop_token stop_token = {}, + std::size_t* sent_count = nullptr); + + [[nodiscard]] std::size_t verified_outstanding_calling() const noexcept { + return negotiated_outstanding_verified_ + ? negotiated_.maximum_outstanding_calling : 1U; + } + [[nodiscard]] MmsPduEnvelope poll_once( std::stop_token stop_token = {}); @@ -256,6 +279,8 @@ class MmsAssociationRuntime final { MmsAssociationRuntimeState state_{MmsAssociationRuntimeState::disconnected}; MmsEndpoint endpoint_; MmsNegotiatedAssociation negotiated_; + // Unknown or tolerant Initiate-Response is strictly serial. + bool negotiated_outstanding_verified_{}; MmsInvokeRouter invoke_router_; osi::TpktStreamDecoder tpkt_decoder_; std::deque> information_reports_; diff --git a/src/mms/association_runtime.cpp b/src/mms/association_runtime.cpp index f5ff43aa3..9d3625ae0 100644 --- a/src/mms/association_runtime.cpp +++ b/src/mms/association_runtime.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include namespace ar::iec61850::mms { @@ -391,6 +392,7 @@ void MmsAssociationRuntime::connect( information_reports_.clear(); tpkt_decoder_.reset(); next_invoke_id_ = 0U; + negotiated_outstanding_verified_ = false; remote_cotp_reference_ = 0U; negotiated_ = {}; negotiated_.tpdu_size_code = options_.tpdu_size_code; @@ -413,6 +415,7 @@ void MmsAssociationRuntime::connect( information_reports_.clear(); tpkt_decoder_.reset(); remote_cotp_reference_ = 0U; + negotiated_outstanding_verified_ = false; negotiated_ = {}; negotiated_.tpdu_size_code = options_.tpdu_size_code; negotiated_.presentation_context_id = options_.presentation_context_id; @@ -531,6 +534,8 @@ void MmsAssociationRuntime::connect( initiate.negotiated_maximum_outstanding_called; negotiated_.data_structure_nesting_level = initiate.negotiated_data_structure_nesting_level; + negotiated_outstanding_verified_ = + initiate.negotiated_maximum_outstanding_calling > 0U; } catch (const std::exception& exception) { strict_failure = std::string{"Strict MMS Initiate-Response decode failed: "} + @@ -555,6 +560,7 @@ void MmsAssociationRuntime::connect( throw MmsAssociationRuntimeError(strict_failure); } used_csharp_compatibility = true; + negotiated_outstanding_verified_ = false; acceptance_message = profile.name + ": " + compatibility_message + (strict_failure.empty() @@ -764,6 +770,122 @@ MmsConfirmedExchangeResult MmsAssociationRuntime::exchange_confirmed( } } +std::vector +MmsAssociationRuntime::exchange_confirmed_batch( + const std::span requests, + const std::stop_token stop_token, + std::size_t* const sent_count) { + if (sent_count) *sent_count = 0U; + require_associated(); + // Bound the collection independently of server claims. Caller must opt in. + constexpr std::size_t hard_window = 8U; + const auto window = std::min( + hard_window, verified_outstanding_calling()); + if (requests.empty() || requests.size() > window) { + throw std::invalid_argument( + "Confirmed MMS batch exceeds verified negotiated calling window."); + } + std::unordered_map slots; + slots.reserve(requests.size()); + for (std::size_t i = 0U; i < requests.size(); ++i) { + const auto& req = requests[i]; + if (req.invoke_id == 0U || + req.invoke_id > MmsPduCodec::maximum_invoke_id || + !slots.emplace(req.invoke_id, i).second) { + throw std::invalid_argument( + "Confirmed MMS batch contains invalid or duplicate invoke ID."); + } + const auto pdu = MmsPduCodec::decode_envelope(req.presentation_payload); + if (pdu.kind != MmsPduKind::confirmed_request || + !pdu.matches_invoke(req.invoke_id)) { + throw std::invalid_argument( + "Confirmed MMS batch PDU does not match its invoke ID."); + } + } + + const auto deadline = deadline_after(options_.request_timeout); + try { + if (invoke_router_.queued_confirmed_count() != 0U) { + throw MmsAssociationRuntimeError( + "Orphan confirmed response before MMS batch send."); + } + for (const auto& req : requests) { + require_not_cancelled(stop_token); + send_application_payload(req.presentation_payload, deadline, stop_token); + if (sent_count) ++*sent_count; + add_event(MmsAssociationEventKind::confirmed_request_sent, + "Batched confirmed MMS request sent.", req.invoke_id); + } + + std::vector> completed(requests.size()); + std::size_t remaining = requests.size(); + for (std::size_t received_count = 0U; + received_count < options_.maximum_receive_chunks_per_operation; + ++received_count) { + auto bytes = receive_application_payload(deadline, stop_token); + auto envelope = route_received_payload(bytes); + if (!envelope.confirmed_result()) continue; // InformationReport + const auto found = envelope.invoke_id + ? slots.find(*envelope.invoke_id) : slots.end(); + if (found == slots.end() || completed[found->second].has_value()) { + throw MmsAssociationRuntimeError( + "Unregistered or duplicate confirmed MMS batch invoke ID."); + } + MmsPduEnvelope matched; + if (!invoke_router_.try_dequeue(*envelope.invoke_id, matched)) { + throw MmsAssociationRuntimeError( + "Batch response absent from authoritative invoke router."); + } + completed[found->second] = MmsConfirmedExchangeResult{ + std::move(matched), std::move(bytes)}; + add_event(MmsAssociationEventKind::confirmed_result_received, + "Batched confirmed MMS result received.", *envelope.invoke_id); + if (--remaining == 0U) { + std::vector results; + results.reserve(completed.size()); + for (auto& item : completed) results.push_back(std::move(*item)); + return results; + } + } + throw MmsAssociationRuntimeError( + "MMS batch exhausted bounded receive iteration budget."); + } catch (const MmsTransportCancelledError& error) { + state_ = MmsAssociationRuntimeState::faulted; + last_fault_ = error.what(); + add_event(MmsAssociationEventKind::cancelled, error.what()); + transport_.close(); + invoke_router_.clear(); + information_reports_.clear(); + tpkt_decoder_.reset(); + remote_cotp_reference_ = 0U; + throw; + } catch (const MmsTransportTimeoutError& error) { + state_ = MmsAssociationRuntimeState::faulted; + last_fault_ = error.what(); + add_event(MmsAssociationEventKind::timed_out, error.what()); + transport_.close(); + invoke_router_.clear(); + information_reports_.clear(); + tpkt_decoder_.reset(); + remote_cotp_reference_ = 0U; + throw; + } catch (const std::exception& error) { + if (stop_token.stop_requested()) { + state_ = MmsAssociationRuntimeState::faulted; + last_fault_ = error.what(); + add_event(MmsAssociationEventKind::cancelled, error.what()); + } else { + fail(error.what()); + } + transport_.close(); + invoke_router_.clear(); + information_reports_.clear(); + tpkt_decoder_.reset(); + remote_cotp_reference_ = 0U; + throw; + } +} + MmsPduEnvelope MmsAssociationRuntime::poll_once( const std::stop_token stop_token) { require_associated(); From 50f2d42403306a248e03214b14a19f84dfce4ea8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 17:58:43 +0700 Subject: [PATCH 2/5] feat(discovery): opt-in verified GetNameList wire pipeline with sequential pages --- include/ariec61850/mms/live_discovery.hpp | 14 ++ src/mms/live_discovery.cpp | 165 +++++++++++++++++++++- 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/mms/live_discovery.hpp b/include/ariec61850/mms/live_discovery.hpp index 66a191991..26a1f3f19 100644 --- a/include/ariec61850/mms/live_discovery.hpp +++ b/include/ariec61850/mms/live_discovery.hpp @@ -25,6 +25,11 @@ struct MmsLiveDiscoveryOptions final { std::size_t maximum_domains{4'096U}; std::size_t maximum_names_per_domain{65'536U}; + // Opt-in association-owned GetNameList wire pipelining (not threads). + // Default 1 preserves legacy serial order and behavior. Effective window + // is min(requested, verified peer calling limit, hard cap of 8). + std::size_t max_concurrent_name_list_chains{1U}; + bool probe_variable_types{true}; std::size_t maximum_variable_type_probes{4'096U}; @@ -77,6 +82,8 @@ struct MmsReportControlEvidence final { // count. Pure accounting: no extra MMS service or scheduling side effects. struct MmsLiveDiscoveryWireBudget final { std::size_t get_name_list{}; + // Observed send window, not a requested/conjectured concurrency value. + std::size_t name_list_peak_outstanding{}; std::size_t get_variable_access_attributes{}; std::size_t get_named_variable_list_attributes{}; std::size_t read_report_controls{}; @@ -135,6 +142,13 @@ class MmsLiveDiscoveryClient final { MmsLiveDiscoveryWireBudget& wire_budget, std::stop_token stop_token); + void get_name_lists_pipelined( + const std::vector& domains, + const MmsLiveDiscoveryOptions& options, + MmsLiveDiscoveryResult& result, + std::stop_token stop_token, + std::size_t window); + MmsAssociationRuntime& association_; }; diff --git a/src/mms/live_discovery.cpp b/src/mms/live_discovery.cpp index d89e09a33..6b4124727 100644 --- a/src/mms/live_discovery.cpp +++ b/src/mms/live_discovery.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -19,7 +20,9 @@ namespace { void validate_options(const MmsLiveDiscoveryOptions& options) { if (options.maximum_pages_per_query == 0U || options.maximum_domains == 0U || - options.maximum_names_per_domain == 0U) { + options.maximum_names_per_domain == 0U || + options.max_concurrent_name_list_chains == 0U || + options.max_concurrent_name_list_chains > 8U) { throw std::invalid_argument("Live MMS discovery name-list limits are invalid."); } if (options.probe_variable_types && @@ -267,6 +270,8 @@ std::string MmsLiveDiscoveryResult::summary() const { << ", GVA=" << wire_budget.get_variable_access_attributes << ", DataSetDirectory=" << wire_budget.get_named_variable_list_attributes << ", RCBRead=" << wire_budget.read_report_controls << ']' + << ", nameListPeakOutstanding=" + << wire_budget.name_list_peak_outstanding << ", diagnostics=" << diagnostics.size() << '.'; return stream.str(); } @@ -359,6 +364,153 @@ std::vector MmsLiveDiscoveryClient::get_name_list( " exceeded the configured page bound."); } +void MmsLiveDiscoveryClient::get_name_lists_pipelined( + const std::vector& domains, + const MmsLiveDiscoveryOptions& options, + MmsLiveDiscoveryResult& result, + const std::stop_token stop_token, + const std::size_t window) { + struct Chain final { + std::size_t domain_index{}; + MmsGetNameListObjectClass object_class{}; + std::vector names; + std::set> seen; + std::string continue_after; + std::size_t pages{}; + }; + // Each domain/class is its own sequential continuation chain; only + // independent chains may be outstanding together. All socket ownership + // stays on this one call stack and one MMS association. + std::vector chains; + chains.reserve(domains.size() * 2U); + std::deque ready; + for (std::size_t i = 0U; i < domains.size(); ++i) { + for (const auto cls : {MmsGetNameListObjectClass::named_variable, + MmsGetNameListObjectClass::named_variable_list}) { + ready.push_back(chains.size()); + chains.push_back(Chain{i, cls}); + } + } + + auto publish = [&](Chain& chain) { + const auto& domain = domains[chain.domain_index]; + if (chain.object_class == MmsGetNameListObjectClass::named_variable) { + result.names.domain_variables.at(domain) = std::move(chain.names); + } else { + result.names.domain_variable_lists.emplace(domain, std::move(chain.names)); + } + }; + auto failed = [&](const Chain& chain, const std::exception& error) { + if (!options.continue_on_optional_probe_error) throw; + const auto& domain = domains[chain.domain_index]; + result.diagnostics.push_back( + optional_probe_error(object_class_name(chain.object_class), domain, error)); + if (chain.object_class == MmsGetNameListObjectClass::named_variable_list) { + result.names.domain_variable_lists.try_emplace(domain); + } + }; + + while (!ready.empty() && association_.associated()) { + std::vector chosen; + std::vector requests; + chosen.reserve(window); + requests.reserve(window); + while (!ready.empty() && chosen.size() < window) { + const auto index = ready.front(); + ready.pop_front(); + auto& chain = chains[index]; + MmsGetNameListRequest request; + request.invoke_id = association_.next_invoke_id(); + request.object_class = chain.object_class; + request.scope = MmsObjectScopeKind::domain_specific; + request.domain_id = domains[chain.domain_index]; + request.continue_after = chain.continue_after; + ++chain.pages; + chosen.push_back(index); + requests.push_back(MmsConfirmedBatchRequest{ + MmsServiceCodec::encode_get_name_list_request_p_data( + request, association_.negotiated().presentation_context_id), + request.invoke_id}); + } + + // Preserve actual wire send receipts even if the batch times out or a + // later segmented write fails. No extra network request for metrics. + struct Receipt final { + MmsLiveDiscoveryWireBudget& budget; + std::size_t sent{}; + ~Receipt() { + budget.get_name_list += sent; + budget.name_list_peak_outstanding = + std::max(budget.name_list_peak_outstanding, sent); + } + } receipt{result.wire_budget}; + std::vector exchanges; + try { + if (requests.size() == 1U) { + bool sent = false; + try { + exchanges.push_back(association_.exchange_confirmed( + requests.front().presentation_payload, + requests.front().invoke_id, stop_token, &sent)); + } catch (...) { + receipt.sent = sent ? 1U : 0U; + throw; + } + receipt.sent = sent ? 1U : 0U; + } else { + exchanges = association_.exchange_confirmed_batch( + requests, stop_token, &receipt.sent); + } + } catch (const std::exception& error) { + if (!options.continue_on_optional_probe_error) throw; + // No unconfirmed page is published. Preserve completed prior + // chains/domains and one concrete diagnostic for this batch. + result.diagnostics.push_back( + std::string{"NameList pipeline fault: "} + error.what()); + if (!association_.associated()) break; + // A non-fatal local failure is terminal for this selected group; + // other unsent groups are not guessed or fabricated. + break; + } + + for (std::size_t pos = 0U; pos < chosen.size(); ++pos) { + auto& chain = chains[chosen[pos]]; + try { + const auto payload = response_payload(exchanges[pos], "GetNameList"); + const auto response = MmsServiceCodec::decode_get_name_list_response( + payload, requests[pos].invoke_id); + const auto count_before = chain.names.size(); + for (const auto& name : response.names) { + if (chain.seen.insert(name).second) { + if (chain.names.size() >= options.maximum_names_per_domain) { + throw MmsLiveDiscoveryError( + "GetNameList name bound exceeded for " + + domains[chain.domain_index]); + } + chain.names.push_back(name); + } + } + if (!response.more_follows) { + publish(chain); + } else { + if (response.names.empty() || chain.names.size() == count_before) { + throw MmsLiveDiscoveryError( + "GetNameList moreFollows without forward progress."); + } + if (chain.pages >= options.maximum_pages_per_query) { + throw MmsLiveDiscoveryError( + "GetNameList continuation page bound exceeded."); + } + chain.continue_after = response.names.back(); + ready.push_back(chosen[pos]); + } + } catch (const std::exception& error) { + failed(chain, error); + } + } + } +} + MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( const MmsLiveDiscoveryOptions& options, const std::stop_token stop_token) { @@ -386,6 +538,16 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( for (const auto& domain : domains) { result.names.domain_variables.try_emplace(domain); } + // VMD enumeration is always serial. A strict association must prove its + // calling window; tolerant/unknown peers remain on the original path. + result.wire_budget.name_list_peak_outstanding = + result.wire_budget.get_name_list == 0U ? 0U : 1U; + const auto name_window = std::min( + options.max_concurrent_name_list_chains, + std::min(8U, association_.verified_outstanding_calling())); + if (name_window > 1U) { + get_name_lists_pipelined(domains, options, result, stop_token, name_window); + } else { for (const auto& domain : domains) { // A failed confirmed exchange closes the association. Keep earlier // successful domain evidence; never manufacture additional failed @@ -428,6 +590,7 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( } } + } result.report_inventory = MmsReportInventoryBuilder::build(result.names); const auto readDataSetDirectories = [&] { From 5149e22749f05b21621c1a9c12dec8a1c1f616b8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:00:18 +0700 Subject: [PATCH 3/5] test(mms): prove pipelined out-of-order routing, pagination parity and fail-closed receipts --- tests/test_live_discovery.cpp | 108 ++++++++++++++++++++++++++++++++++ tests/test_mms_runtime.cpp | 100 +++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp index 9a7fc8068..bd9a93cd2 100644 --- a/tests/test_live_discovery.cpp +++ b/tests/test_live_discovery.cpp @@ -729,6 +729,112 @@ void association_loss_preserves_completed_domain_evidence_without_dead_probes() CHECK(transport.sent().size() == 5U); // 2 handshake + exactly 3 sends } + +void negotiated_name_list_pipeline_preserves_model_and_wire_budget() { + const auto run = [](const bool pipeline, const bool broken_continuation) { + ScriptedTransport transport; + queue_handshake(transport); + const auto response = [&](const std::uint32_t id, + const std::vector& names, + const bool more) { + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {id, names, more}))); + }; + response(1U, {"ALPHALD0", "ALPHAPROT"}, false); + if (pipeline) { + // The first four independent chains are in-flight together. + // Respond deliberately out of invoke order, including an empty + // valid DataSet list; continuation remains sequential per chain. + response(4U, {"MMXU1$MX$PhV$phsA$cVal$mag$f"}, false); + response(3U, {"LLN0$DataSetA"}, false); + response(2U, {"LLN0$ST$Mod$stVal"}, true); + response(5U, {}, false); + response(6U, broken_continuation + ? std::vector{} + : std::vector{"PTOC1$ST$Str$stVal"}, + broken_continuation); + } else { + response(2U, {"LLN0$ST$Mod$stVal"}, true); + response(3U, {"PTOC1$ST$Str$stVal"}, false); + response(4U, {"LLN0$DataSetA"}, false); + response(5U, {"MMXU1$MX$PhV$phsA$cVal$mag$f"}, false); + response(6U, {}, false); + } + mms::MmsAssociationRuntime association{transport}; + association.connect({"127.0.0.1", 102U}); + mms::MmsLiveDiscoveryClient discovery{association}; + mms::MmsLiveDiscoveryOptions options; + options.max_concurrent_name_list_chains = pipeline ? 4U : 1U; + options.probe_variable_types = false; + options.read_data_set_directories = false; + options.probe_report_controls = false; + auto result = discovery.discover(options); + + CHECK(result.wire_budget.get_name_list == 6U); + CHECK(result.wire_budget.total() == 6U); + CHECK(result.wire_budget.name_list_peak_outstanding == (pipeline ? 4U : 1U)); + CHECK(transport.sent().size() == 8U); + if (pipeline) { + // The first continuation may only be transmitted after the first + // batch has completed. It must use the exact previous page cursor. + const auto tpkt = osi::TpktFrameCodec::decode(transport.sent()[7]); + const auto cotp = osi::CotpFrameCodec::decode(tpkt.payload); + const auto request = + mms::MmsServiceCodec::decode_get_name_list_request(cotp.user_data); + CHECK(request.invoke_id == 6U); + CHECK(request.domain_id == "ALPHALD0"); + CHECK(request.object_class == mms::MmsGetNameListObjectClass::named_variable); + CHECK(request.continue_after == "LLN0$ST$Mod$stVal"); + } + if (broken_continuation) { + CHECK(result.partial()); + CHECK(result.names.domain_variables.at("ALPHALD0").empty()); + CHECK(result.names.domain_variables.at("ALPHAPROT").size() == 1U); + } + return result; + }; + + const auto legacy = run(false, false); + const auto pipelined = run(true, false); + CHECK(!pipelined.partial()); + CHECK(legacy.names.domain_variables == pipelined.names.domain_variables); + CHECK(legacy.names.domain_variable_lists == pipelined.names.domain_variable_lists); + CHECK(legacy.report_inventory.data_sets.size() == + pipelined.report_inventory.data_sets.size()); + CHECK(legacy.report_inventory.report_controls.size() == + pipelined.report_inventory.report_controls.size()); + CHECK(pipelined.summary().find("nameListPeakOutstanding=4") != std::string::npos); + static_cast(run(true, true)); +} + +void name_list_batch_fault_retains_only_verified_domain_evidence() { + ScriptedTransport transport; + queue_handshake(transport); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {1U, {"ALPHALD0", "ALPHAPROT"}, false}))); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {99U, {"UNEXPECTED"}, false}))); + mms::MmsAssociationRuntime association{transport}; + association.connect({"127.0.0.1", 102U}); + mms::MmsLiveDiscoveryClient discovery{association}; + mms::MmsLiveDiscoveryOptions options; + options.max_concurrent_name_list_chains = 4U; + options.probe_variable_types = false; + options.read_data_set_directories = false; + options.probe_report_controls = false; + const auto result = discovery.discover(options); + CHECK(result.partial()); + CHECK(!association.associated()); + CHECK(result.domain_count() == 2U); + CHECK(result.variable_count() == 0U); + CHECK(result.variable_list_count() == 0U); + CHECK(result.wire_budget.get_name_list == 5U); + CHECK(result.wire_budget.name_list_peak_outstanding == 4U); +} + void pagination_without_forward_progress_is_rejected() { ScriptedTransport transport; queue_handshake(transport); @@ -762,6 +868,8 @@ int main() { verified_data_set_hints_restore_late_ln_type_schema(); disconnected_optional_probes_do_not_inflate_wire_budget(); association_loss_preserves_completed_domain_evidence_without_dead_probes(); + negotiated_name_list_pipeline_preserves_model_and_wire_budget(); + name_list_batch_fault_retains_only_verified_domain_evidence(); pagination_without_forward_progress_is_rejected(); std::cout << "Live MMS discovery and model parity tests passed.\n"; return 0; diff --git a/tests/test_mms_runtime.cpp b/tests/test_mms_runtime.cpp index ba54b480b..f970163da 100644 --- a/tests/test_mms_runtime.cpp +++ b/tests/test_mms_runtime.cpp @@ -450,6 +450,104 @@ void confirmed_exchange_rejects_idle_orphan_without_sending() { CHECK(!transport.connected()); } + +void confirmed_batch_dispatches_out_of_order_with_report() { + ScriptedTransport transport; + queue_handshake(transport); + mms::MmsAssociationRuntime runtime{transport}; + runtime.connect({"127.0.0.1", 102U}); + CHECK(runtime.verified_outstanding_calling() >= 2U); + + std::vector requests; + for (std::size_t i = 0U; i < 2U; ++i) { + mms::MmsReadRequest request; + request.invoke_id = runtime.next_invoke_id(); + request.variables.push_back( + mms::MmsObjectName::domain_specific("LD0", "LLN0$ST$Mod$stVal")); + requests.push_back({ + mms::MmsServiceCodec::encode_read_request_p_data(request), + request.invoke_id}); + } + + transport.push_receive(wrap_application( + mms::MmsInformationReportCodec::encode_p_data(make_report()))); + for (const auto index : {1U, 0U}) { + mms::MmsReadResponse response; + response.invoke_id = requests[index].invoke_id; + response.results.push_back({ + mms::MmsDataValue::boolean(index == 0U), std::nullopt}); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_read_response_p_data(response))); + } + std::size_t sent = 0U; + const auto result = runtime.exchange_confirmed_batch(requests, {}, &sent); + CHECK(sent == 2U); + CHECK(transport.sent().size() == 4U); // handshake + two complete requests + CHECK(result.size() == 2U); + CHECK(result[0].envelope.matches_invoke(requests[0].invoke_id)); + CHECK(result[1].envelope.matches_invoke(requests[1].invoke_id)); + CHECK(runtime.queued_information_report_count() == 1U); + CHECK(runtime.associated()); + + // Invalid batches fail before any wire work; no speculative retries. + const auto count_before = transport.sent().size(); + const std::vector duplicate{ + requests[0], requests[0]}; + check_throws([&] { + static_cast(runtime.exchange_confirmed_batch(duplicate)); + }); + CHECK(transport.sent().size() == count_before); + CHECK(runtime.associated()); + const std::vector over_cap( + 9U, requests[0]); + check_throws([&] { + static_cast(runtime.exchange_confirmed_batch(over_cap)); + }); + CHECK(runtime.associated()); +} + +void confirmed_batch_unregistered_response_faults_and_receipts_survive() { + ScriptedTransport transport; + queue_handshake(transport); + mms::MmsAssociationRuntime runtime{transport}; + runtime.connect({"127.0.0.1", 102U}); + std::vector requests; + for (std::size_t i = 0U; i < 2U; ++i) { + mms::MmsReadRequest request; + request.invoke_id = runtime.next_invoke_id(); + request.variables.push_back( + mms::MmsObjectName::domain_specific("LD0", "LLN0$ST$Mod$stVal")); + requests.push_back({ + mms::MmsServiceCodec::encode_read_request_p_data(request), + request.invoke_id}); + } + mms::MmsReadResponse rogue; + rogue.invoke_id = 77U; + rogue.results.push_back({mms::MmsDataValue::boolean(false), std::nullopt}); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_read_response_p_data(rogue))); + std::size_t sent = 0U; + check_throws([&] { + static_cast(runtime.exchange_confirmed_batch(requests, {}, &sent)); + }); + CHECK(sent == 2U); + CHECK(!runtime.associated()); + CHECK(!transport.connected()); + CHECK(transport.sent().size() == 4U); + queue_handshake(transport); + runtime.connect({"127.0.0.1", 102U}); + CHECK(runtime.associated()); + + std::size_t timeout_sent = 0U; + check_throws([&] { + static_cast(runtime.exchange_confirmed_batch( + requests, {}, &timeout_sent)); + }); + CHECK(timeout_sent == 2U); + CHECK(!runtime.associated()); + CHECK(!transport.connected()); +} + [[nodiscard]] mms::MmsReportControlCandidate make_urcb_candidate() { mms::MmsReportControlCandidate candidate; candidate.domain = "LD0"; @@ -766,6 +864,8 @@ int main() { {"confirmed exchange timeout closes transport and reconnects", confirmed_exchange_timeout_closes_transport_and_can_reconnect}, {"unexpected confirmed invoke fails closed", confirmed_exchange_rejects_unexpected_invoke_instead_of_caching_it}, {"orphan confirmed result cannot acknowledge unsent request", confirmed_exchange_rejects_idle_orphan_without_sending}, + {"batch out-of-order response and interleaved report", confirmed_batch_dispatches_out_of_order_with_report}, + {"batch unexpected invoke and timeout fault containment", confirmed_batch_unregistered_response_faults_and_receipts_survive}, {"report subscription lifecycle", subscription_runtime_reserves_enables_receives_and_cleans_up}, {"report cleanup after association loss", subscription_marks_cleanup_required_when_association_is_lost}, {"report takeover protection", subscription_does_not_take_over_an_enabled_rcb}, From b4913873a229f3d3a801513d6f8a48c110ed3269 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:01:58 +0700 Subject: [PATCH 4/5] feat(cli): opt-in verified NameList wire pipeline for benchmark and relay trials --- tools/live_discover.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tools/live_discover.cpp b/tools/live_discover.cpp index 72b17e516..79327a5d6 100644 --- a/tools/live_discover.cpp +++ b/tools/live_discover.cpp @@ -144,6 +144,8 @@ void print_usage() { << " --no-types Skip GetVariableAccessAttributes probes.\n" << " --no-datasets Skip DataSet directory reads.\n" << " --no-rcb Skip read-only RCB attribute probes.\n" + << " --name-list-window N Opt-in bounded GetNameList wire pipeline (1..8; default 1).\n" + << " Requires verified peer calling window; otherwise serial.\n" << " --max-types N Bound all type probes.\n" << " --max-datasets N Bound DataSet directory reads.\n" << " --max-rcb N Bound RCB read probes.\n" @@ -571,6 +573,7 @@ int main(int argc, char** argv) { } else if (option == "--no-polling-fallback") { rcb_plan_options.allow_polling_fallback = false; } else if (option == "--ied-name" || option == "--max-types" || + option == "--name-list-window" || option == "--max-datasets" || option == "--max-rcb" || option == "--timeout-ms" || option == "--rcb-plan" || option == "--rcb-ld" || option == "--rcb-dataset" || @@ -609,7 +612,13 @@ int main(int argc, char** argv) { } const auto parsed = parse_limit(option, value); - if (option == "--max-types") { + if (option == "--name-list-window") { + if (parsed > 8U) { + throw std::invalid_argument( + "--name-list-window requires 1..8."); + } + discovery_options.max_concurrent_name_list_chains = parsed; + } else if (option == "--max-types") { discovery_options.maximum_variable_type_probes = parsed; } else if (option == "--max-datasets") { discovery_options.maximum_data_set_directories = parsed; From 88ba0419af3866938be7a69c57cdc6cea6c56457 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:04:35 +0700 Subject: [PATCH 5/5] fix(mms): initialize paginated discovery chain completely under strict warnings --- src/mms/live_discovery.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/mms/live_discovery.cpp b/src/mms/live_discovery.cpp index 6b4124727..7f6fd39d3 100644 --- a/src/mms/live_discovery.cpp +++ b/src/mms/live_discovery.cpp @@ -388,7 +388,10 @@ void MmsLiveDiscoveryClient::get_name_lists_pipelined( for (const auto cls : {MmsGetNameListObjectClass::named_variable, MmsGetNameListObjectClass::named_variable_list}) { ready.push_back(chains.size()); - chains.push_back(Chain{i, cls}); + Chain chain; + chain.domain_index = i; + chain.object_class = cls; + chains.push_back(std::move(chain)); } }