From 806b56a0e94f2fc07484ea9caa3855f7ec22f5e6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 20:52:08 +0700 Subject: [PATCH 1/2] discovery: capture zero-traffic per-service wire request budgets --- include/ariec61850/mms/live_discovery.hpp | 16 ++++++++++++++++ src/mms/live_discovery.cpp | 9 ++++++++- tests/test_live_discovery.cpp | 10 ++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/mms/live_discovery.hpp b/include/ariec61850/mms/live_discovery.hpp index 52fe9938e..a2166c3bf 100644 --- a/include/ariec61850/mms/live_discovery.hpp +++ b/include/ariec61850/mms/live_discovery.hpp @@ -72,10 +72,25 @@ struct MmsReportControlEvidence final { [[nodiscard]] bool success() const noexcept { return state.has_value(); } }; +// Actual outbound confirmed-service attempts, including failed responses. +// Pure accounting: no extra MMS service and no influence on scheduling. +struct MmsLiveDiscoveryWireBudget final { + std::size_t get_name_list{}; + std::size_t get_variable_access_attributes{}; + std::size_t get_named_variable_list_attributes{}; + std::size_t read_report_controls{}; + + [[nodiscard]] std::size_t total() const noexcept { + return get_name_list + get_variable_access_attributes + + get_named_variable_list_attributes + read_report_controls; + } +}; + struct MmsLiveDiscoveryResult final { MmsEndpoint endpoint; std::string association_profile; std::vector association_attempts; + MmsLiveDiscoveryWireBudget wire_budget; MmsDiscoverySnapshot names; MmsReportInventory report_inventory; std::vector variable_types; @@ -116,6 +131,7 @@ class MmsLiveDiscoveryClient final { MmsGetNameListObjectClass object_class, const std::string& domain, const MmsLiveDiscoveryOptions& options, + MmsLiveDiscoveryWireBudget& wire_budget, std::stop_token stop_token); MmsAssociationRuntime& association_; diff --git a/src/mms/live_discovery.cpp b/src/mms/live_discovery.cpp index 9eeb964b1..cb87868ae 100644 --- a/src/mms/live_discovery.cpp +++ b/src/mms/live_discovery.cpp @@ -273,6 +273,7 @@ std::vector MmsLiveDiscoveryClient::get_name_list( const MmsGetNameListObjectClass object_class, const std::string& domain, const MmsLiveDiscoveryOptions& options, + MmsLiveDiscoveryWireBudget& wire_budget, const std::stop_token stop_token) { std::vector names; std::set> seen; @@ -290,6 +291,7 @@ std::vector MmsLiveDiscoveryClient::get_name_list( const auto encoded = MmsServiceCodec::encode_get_name_list_request_p_data( request, association_.negotiated().presentation_context_id); + ++wire_budget.get_name_list; const auto exchange = association_.exchange_confirmed( encoded, request.invoke_id, stop_token); const auto payload = response_payload(exchange, "GetNameList"); @@ -342,7 +344,7 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( result.association_attempts = association_.association_attempts(); const auto domains = get_name_list( - MmsGetNameListObjectClass::domain, {}, options, stop_token); + MmsGetNameListObjectClass::domain, {}, options, result.wire_budget, stop_token); if (domains.size() > options.maximum_domains) { throw MmsLiveDiscoveryError( "Domain discovery exceeded the configured domain bound."); @@ -356,6 +358,7 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( MmsGetNameListObjectClass::named_variable, domain, options, + result.wire_budget, stop_token)); } catch (const std::exception& exception) { if (!options.continue_on_optional_probe_error) { @@ -373,6 +376,7 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( MmsGetNameListObjectClass::named_variable_list, domain, options, + result.wire_budget, stop_token)); } catch (const std::exception& exception) { if (!options.continue_on_optional_probe_error) { @@ -403,6 +407,7 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( evidence.candidate.reference); const auto encoded = MmsDataSetDirectoryCodec::encode_request_p_data( request, association_.negotiated().presentation_context_id); + ++result.wire_budget.get_named_variable_list_attributes; const auto exchange = association_.exchange_confirmed( encoded, request.invoke_id, stop_token); const auto payload = response_payload( @@ -452,6 +457,7 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( encode_variable_access_attributes_request_p_data( request, association_.negotiated().presentation_context_id); + ++result.wire_budget.get_variable_access_attributes; const auto exchange = association_.exchange_confirmed( encoded, request.invoke_id, stop_token); const auto payload = response_payload( @@ -509,6 +515,7 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( evidence.requested_attributes); const auto encoded = MmsServiceCodec::encode_read_request_p_data( request, association_.negotiated().presentation_context_id); + ++result.wire_budget.read_report_controls; const auto exchange = association_.exchange_confirmed( encoded, invoke_id, stop_token); const auto payload = response_payload( diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp index 74841a551..b4c1b2aaa 100644 --- a/tests/test_live_discovery.cpp +++ b/tests/test_live_discovery.cpp @@ -480,6 +480,11 @@ void live_discovery_builds_csharp_compatible_read_only_model() { ++read_only_requests; } CHECK(read_only_requests == 6U); + CHECK(result.wire_budget.get_name_list == 3U); + CHECK(result.wire_budget.get_variable_access_attributes == 1U); + CHECK(result.wire_budget.get_named_variable_list_attributes == 1U); + CHECK(result.wire_budget.read_report_controls == 1U); + CHECK(result.wire_budget.total() == read_only_requests); } @@ -628,6 +633,11 @@ void verified_data_set_hints_restore_late_ln_type_schema() { } CHECK(found_cswi); CHECK(transport.sent().size() == 8U); // association (2) + 6 reads + CHECK(result.wire_budget.get_name_list == 3U); + CHECK(result.wire_budget.get_named_variable_list_attributes == 1U); + CHECK(result.wire_budget.get_variable_access_attributes == 2U); + CHECK(result.wire_budget.read_report_controls == 0U); + CHECK(result.wire_budget.total() == 6U); } void pagination_without_forward_progress_is_rejected() { From 4c2dee88d718c25d9ca245f32a935b46842f2676 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 04:33:02 +0700 Subject: [PATCH 2/2] discovery: count only runtime-confirmed wire sends across timeout and disconnect --- .../ariec61850/mms/association_runtime.hpp | 6 ++- include/ariec61850/mms/live_discovery.hpp | 5 +- src/mms/association_runtime.cpp | 5 +- src/mms/live_discovery.cpp | 49 ++++++++++++++----- tests/test_live_discovery.cpp | 45 +++++++++++++++++ 5 files changed, 94 insertions(+), 16 deletions(-) diff --git a/include/ariec61850/mms/association_runtime.hpp b/include/ariec61850/mms/association_runtime.hpp index efa0e47b6..e00fd8d4e 100644 --- a/include/ariec61850/mms/association_runtime.hpp +++ b/include/ariec61850/mms/association_runtime.hpp @@ -172,7 +172,11 @@ class MmsAssociationRuntime final { [[nodiscard]] MmsConfirmedExchangeResult exchange_confirmed( std::span presentation_payload, std::uint32_t expected_invoke_id, - std::stop_token stop_token = {}); + std::stop_token stop_token = {}, + // Optional per-call receipt, independent of the bounded event log. + // True only when the complete confirmed request was sent to transport. + // A response timeout after a successful send still leaves this true. + bool* request_sent = nullptr); [[nodiscard]] MmsPduEnvelope poll_once( std::stop_token stop_token = {}); diff --git a/include/ariec61850/mms/live_discovery.hpp b/include/ariec61850/mms/live_discovery.hpp index a2166c3bf..66a191991 100644 --- a/include/ariec61850/mms/live_discovery.hpp +++ b/include/ariec61850/mms/live_discovery.hpp @@ -72,8 +72,9 @@ struct MmsReportControlEvidence final { [[nodiscard]] bool success() const noexcept { return state.has_value(); } }; -// Actual outbound confirmed-service attempts, including failed responses. -// Pure accounting: no extra MMS service and no influence on scheduling. +// Completed outbound confirmed MMS request sends (including responses that +// later fail or time out). Pre-send failures and post-disconnect probes do not +// count. Pure accounting: no extra MMS service or scheduling side effects. struct MmsLiveDiscoveryWireBudget final { std::size_t get_name_list{}; std::size_t get_variable_access_attributes{}; diff --git a/src/mms/association_runtime.cpp b/src/mms/association_runtime.cpp index 65bdab596..ce69823fd 100644 --- a/src/mms/association_runtime.cpp +++ b/src/mms/association_runtime.cpp @@ -668,7 +668,9 @@ void MmsAssociationRuntime::disconnect(const std::stop_token stop_token) noexcep MmsConfirmedExchangeResult MmsAssociationRuntime::exchange_confirmed( const std::span presentation_payload, const std::uint32_t expected_invoke_id, - const std::stop_token stop_token) { + const std::stop_token stop_token, + bool* const request_sent) { + if (request_sent != nullptr) *request_sent = false; require_associated(); if (expected_invoke_id == 0U || expected_invoke_id > MmsPduCodec::maximum_invoke_id) { @@ -689,6 +691,7 @@ MmsConfirmedExchangeResult MmsAssociationRuntime::exchange_confirmed( const auto deadline = deadline_after(options_.request_timeout); try { send_application_payload(presentation_payload, deadline, stop_token); + if (request_sent != nullptr) *request_sent = true; add_event(MmsAssociationEventKind::confirmed_request_sent, "Confirmed MMS request sent.", expected_invoke_id); diff --git a/src/mms/live_discovery.cpp b/src/mms/live_discovery.cpp index cb87868ae..b25d97ea1 100644 --- a/src/mms/live_discovery.cpp +++ b/src/mms/live_discovery.cpp @@ -266,6 +266,31 @@ std::string MmsLiveDiscoveryResult::summary() const { return stream.str(); } +namespace { + +// Capture the per-exchange send receipt even when the response times out. +// This scoped counter is independent of shared event history and keeps the +// correct service category if a later optional probe fails before sending. +struct ConfirmedSendCount final { + std::size_t& service_count; + bool sent{}; + ~ConfirmedSendCount() { + if (sent) ++service_count; + } +}; + +[[nodiscard]] MmsConfirmedExchangeResult exchange_counted( + MmsAssociationRuntime& association, + const std::span request, + const std::uint32_t invoke_id, + const std::stop_token stop_token, + std::size_t& service_count) { + ConfirmedSendCount receipt{service_count}; + return association.exchange_confirmed(request, invoke_id, stop_token, &receipt.sent); +} + +} // namespace + MmsLiveDiscoveryClient::MmsLiveDiscoveryClient(MmsAssociationRuntime& association) : association_{association} {} @@ -291,9 +316,9 @@ std::vector MmsLiveDiscoveryClient::get_name_list( const auto encoded = MmsServiceCodec::encode_get_name_list_request_p_data( request, association_.negotiated().presentation_context_id); - ++wire_budget.get_name_list; - const auto exchange = association_.exchange_confirmed( - encoded, request.invoke_id, stop_token); + const auto exchange = exchange_counted( + association_, encoded, request.invoke_id, stop_token, + wire_budget.get_name_list); const auto payload = response_payload(exchange, "GetNameList"); const auto response = MmsServiceCodec::decode_get_name_list_response( payload, request.invoke_id); @@ -407,9 +432,9 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( evidence.candidate.reference); const auto encoded = MmsDataSetDirectoryCodec::encode_request_p_data( request, association_.negotiated().presentation_context_id); - ++result.wire_budget.get_named_variable_list_attributes; - const auto exchange = association_.exchange_confirmed( - encoded, request.invoke_id, stop_token); + const auto exchange = exchange_counted( + association_, encoded, request.invoke_id, stop_token, + result.wire_budget.get_named_variable_list_attributes); const auto payload = response_payload( exchange, "GetNamedVariableListAttributes"); evidence.directory = MmsDataSetDirectoryCodec::decode_response( @@ -457,9 +482,9 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( encode_variable_access_attributes_request_p_data( request, association_.negotiated().presentation_context_id); - ++result.wire_budget.get_variable_access_attributes; - const auto exchange = association_.exchange_confirmed( - encoded, request.invoke_id, stop_token); + const auto exchange = exchange_counted( + association_, encoded, request.invoke_id, stop_token, + result.wire_budget.get_variable_access_attributes); const auto payload = response_payload( exchange, "GetVariableAccessAttributes"); evidence.attributes = MmsServiceCodec:: @@ -515,9 +540,9 @@ MmsLiveDiscoveryResult MmsLiveDiscoveryClient::discover( evidence.requested_attributes); const auto encoded = MmsServiceCodec::encode_read_request_p_data( request, association_.negotiated().presentation_context_id); - ++result.wire_budget.read_report_controls; - const auto exchange = association_.exchange_confirmed( - encoded, invoke_id, stop_token); + const auto exchange = exchange_counted( + association_, encoded, invoke_id, stop_token, + result.wire_budget.read_report_controls); const auto payload = response_payload( exchange, "Read RCB attributes"); const auto response = MmsServiceCodec::decode_read_response( diff --git a/tests/test_live_discovery.cpp b/tests/test_live_discovery.cpp index b4c1b2aaa..7cae64465 100644 --- a/tests/test_live_discovery.cpp +++ b/tests/test_live_discovery.cpp @@ -640,6 +640,50 @@ void verified_data_set_hints_restore_late_ln_type_schema() { CHECK(result.wire_budget.total() == 6U); } +void disconnected_optional_probes_do_not_inflate_wire_budget() { + ScriptedTransport transport; + queue_handshake(transport); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {1U, {"OLS501LD0"}, false}))); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {2U, {"LLN0$ST$Mod$stVal", "LLN0$RP$urcbA01$RptEna"}, false}))); + transport.push_receive(wrap_application( + mms::MmsServiceCodec::encode_get_name_list_response_p_data( + {3U, {"LLN0$DataSetA"}, false}))); + // Do not enqueue an answer to the GVA request: the exchange sends one + // confirmed request, then times out and closes this association. + // DataSet and RCB optional phases continue but can no longer send. + mms::MmsAssociationOptions transportOptions; + transportOptions.maximum_events = 1U; // Bounded events must not affect accounting. + mms::MmsAssociationRuntime association{transport, transportOptions}; + association.connect({"127.0.0.1", 102U}); + + mms::MmsLiveDiscoveryOptions options; + options.maximum_variable_type_probes = 1U; + options.maximum_data_set_directories = 1U; + options.maximum_report_control_probes = 1U; + options.continue_on_optional_probe_error = true; + const auto result = mms::MmsLiveDiscoveryClient{association}.discover(options); + + CHECK(!association.associated()); + CHECK(result.partial()); + CHECK(result.domain_count() == 1U); + CHECK(result.wire_budget.get_name_list == 3U); + CHECK(result.wire_budget.get_variable_access_attributes == 1U); + CHECK(result.wire_budget.get_named_variable_list_attributes == 0U); + CHECK(result.wire_budget.read_report_controls == 0U); + CHECK(result.wire_budget.total() == 4U); + CHECK(transport.sent().size() == 6U); // 2 handshake + 4 actual requests + CHECK(result.variable_types.size() == 1U); + CHECK(!result.variable_types.front().success()); + CHECK(result.data_set_directories.size() == 1U); + CHECK(!result.data_set_directories.front().success()); + CHECK(result.report_controls.size() == 1U); + CHECK(!result.report_controls.front().success()); +} + void pagination_without_forward_progress_is_rejected() { ScriptedTransport transport; queue_handshake(transport); @@ -671,6 +715,7 @@ int main() { case_distinct_references_are_not_deduplicated(); case_distinct_ln_type_probes_are_preserved(); verified_data_set_hints_restore_late_ln_type_schema(); + disconnected_optional_probes_do_not_inflate_wire_budget(); pagination_without_forward_progress_is_rejected(); std::cout << "Live MMS discovery and model parity tests passed.\n"; return 0;